diff --git a/.agents/skills/nemoclaw-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-deploy-remote/SKILL.md index 95aaeed01..f9af8bf2c 100644 --- a/.agents/skills/nemoclaw-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-deploy-remote/SKILL.md @@ -54,6 +54,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 ` or run discovery mode with `nemoclaw start --discover-chat-id` to enable it. + ## Step 3: Connect to the Remote Sandbox After deployment finishes, the deploy command opens an interactive shell inside the remote sandbox. @@ -174,6 +177,64 @@ It does not affect Telegram connectivity. $ nemoclaw start ``` +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 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. + +## Step 13: Verify the Services + +Check that the Telegram bridge is running: + +```console +$ nemoclaw status +``` + +The output shows the status of all auxiliary services. + +## Step 14: Send a Message + +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. + +## Step 15: Allow Telegram Chats by Chat ID + +Save the Telegram chat IDs allowed to interact with the agent: + +```console +$ nemoclaw telegram allow 123456789,987654321 +$ nemoclaw start +``` + +To inspect or clear the saved allowlist: + +```console +$ nemoclaw telegram show +$ nemoclaw telegram clear +``` + +## Step 16: Stop the Services + +To stop the Telegram bridge and all other auxiliary services: + +```console +$ nemoclaw stop +``` + ## Reference - [Sandbox Image Hardening](references/sandbox-hardening.md) diff --git a/.agents/skills/nemoclaw-reference/references/architecture.md b/.agents/skills/nemoclaw-reference/references/architecture.md index d485ca66f..8db2187ed 100644 --- a/.agents/skills/nemoclaw-reference/references/architecture.md +++ b/.agents/skills/nemoclaw-reference/references/architecture.md @@ -164,8 +164,8 @@ The following environment variables configure optional services and local access | Variable | Purpose | |---|---| -| `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | -| `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | +| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. | +| `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. | | `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. | diff --git a/.agents/skills/nemoclaw-reference/references/commands.md b/.agents/skills/nemoclaw-reference/references/commands.md index 76a0831f0..8002e89d7 100644 --- a/.agents/skills/nemoclaw-reference/references/commands.md +++ b/.agents/skills/nemoclaw-reference/references/commands.md @@ -196,6 +196,32 @@ Start optional host auxiliary services. This is the cloudflared tunnel when `clo $ nemoclaw start ``` +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. + +| 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 +$ nemoclaw telegram clear +$ nemoclaw telegram discover +``` + ### `nemoclaw stop` Stop host auxiliary services started by `nemoclaw start` (for example cloudflared). diff --git a/.agents/skills/nemoclaw-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-reference/references/troubleshooting.md index 9e87b09e0..a6162cc3b 100644 --- a/.agents/skills/nemoclaw-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-reference/references/troubleshooting.md @@ -211,6 +211,18 @@ The status command detects the sandbox context and reports "active (inside sandb Run `openshell sandbox list` on the host to check the underlying sandbox state. +### `openclaw update` hangs or times out inside the sandbox + +This is expected for the current NemoClaw deployment model. +NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox. + +Do not run `openclaw update` inside the sandbox. +Instead: + +1. Upgrade to a NemoClaw release that includes the newer `openclaw` version. +2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image. +3. Back up any workspace files you need, then recreate the sandbox so it uses the rebuilt image. + ### Inference requests time out Verify that the inference provider endpoint is reachable from the host. diff --git a/Dockerfile b/Dockerfile index 134e41e39..84eb9f690 100644 --- a/Dockerfile +++ b/Dockerfile @@ -131,6 +131,7 @@ config = { \ 'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \ 'models': {'mode': 'merge', 'providers': providers}, \ 'channels': dict({'defaults': {'configWrites': False}}, **_ch_cfg), \ + 'update': {'checkOnStart': False}, \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index e4b96abc4..7f66d0b64 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -18,7 +18,7 @@ function envInt(name, fallback) { const n = Number(raw); return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback; } -const { ROOT, SCRIPTS, redact, run, runCapture, shellQuote } = require("./runner"); +const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote } = require("./runner"); const { stageOptimizedSandboxBuildContext } = require("./sandbox-build-context"); const { getDefaultOllamaModel, @@ -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"); @@ -773,12 +774,18 @@ async function promptBraveSearchApiKey() { } } -async function ensureValidatedBraveSearchCredential() { - let apiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); - let usingSavedKey = Boolean(apiKey); +async function ensureValidatedBraveSearchCredential(nonInteractive = isNonInteractive()) { + const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); + let apiKey = savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); + let usingSavedKey = Boolean(savedApiKey); while (true) { if (!apiKey) { + if (nonInteractive) { + throw new Error( + "Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode.", + ); + } apiKey = await promptBraveSearchApiKey(); usingSavedKey = false; } @@ -798,6 +805,12 @@ async function ensureValidatedBraveSearchCredential() { console.error(` ${validation.message}`); } + if (nonInteractive) { + throw new Error( + validation.message || "Brave Search API key validation failed in non-interactive mode.", + ); + } + const action = await promptBraveSearchRecovery(validation); if (action === "skip") { console.log(" Skipping Brave Web Search setup."); @@ -1912,22 +1925,21 @@ async function promptValidatedSandboxName() { "NEMOCLAW_SANDBOX_NAME", "my-assistant", ); - const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); + const sandboxName = (nameAnswer || "my-assistant").trim(); - // 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()) { @@ -1958,7 +1970,10 @@ async function createSandbox( ) { step(6, 8, "Creating sandbox"); - const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); + const sandboxName = + sandboxNameOverride !== null && sandboxNameOverride !== undefined + ? validateSandboxName(String(sandboxNameOverride).trim()) + : await promptValidatedSandboxName(); const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; // Check whether messaging providers will be needed — this must happen before @@ -2225,11 +2240,11 @@ async function createSandbox( // or seeing 502/503 errors during initial load. console.log(" Waiting for NemoClaw dashboard to become ready..."); for (let i = 0; i < 15; i++) { - const readyMatch = runCapture( - `openshell sandbox exec ${shellQuote(sandboxName)} curl -sf http://localhost:18789/ 2>/dev/null || echo "no"`, + const readyMatch = runCaptureOpenshell( + ["sandbox", "exec", sandboxName, "curl", "-sf", `http://localhost:${CONTROL_UI_PORT}/`], { ignoreError: true }, ); - if (readyMatch && !readyMatch.includes("no")) { + if (readyMatch) { console.log(" ✓ Dashboard is live"); break; } @@ -2254,10 +2269,9 @@ async function createSandbox( // DNS proxy — run a forwarder in the sandbox pod so the isolated // sandbox namespace can resolve hostnames (fixes #626). console.log(" Setting up sandbox DNS proxy..."); - run( - `bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${shellQuote(GATEWAY_NAME)} ${shellQuote(sandboxName)} 2>&1 || true`, - { ignoreError: true }, - ); + runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { + ignoreError: true, + }); // Check that messaging providers exist in the gateway (sandbox attachment // cannot be verified via CLI yet — only gateway-level existence is checked). @@ -4023,35 +4037,13 @@ async function onboard(opts = {}) { break; } - if (webSearchConfig) { - note(" [resume] Revalidating Brave Search configuration."); - const braveApiKey = await ensureValidatedBraveSearchCredential(); - if (braveApiKey) { - webSearchConfig = { fetchEnabled: true }; - onboardSession.updateSession((current) => { - current.webSearchConfig = webSearchConfig; - return current; - }); - note(" [resume] Reusing Brave Search configuration."); - } else { - webSearchConfig = await configureWebSearch(null); - onboardSession.updateSession((current) => { - current.webSearchConfig = webSearchConfig; - return current; - }); - } - } else { - webSearchConfig = await configureWebSearch(webSearchConfig); - onboardSession.updateSession((current) => { - current.webSearchConfig = webSearchConfig; - return current; - }); - } - const sandboxReuseState = getSandboxReuseState(sandboxName); const resumeSandbox = resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; if (resumeSandbox) { + if (webSearchConfig) { + note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); + } skippedStepMessage("sandbox", sandboxName); } else { if (resume && session?.steps?.sandbox?.status === "complete") { @@ -4067,8 +4059,18 @@ async function onboard(opts = {}) { } } } + let nextWebSearchConfig = webSearchConfig; + if (nextWebSearchConfig) { + note(" [resume] Revalidating Brave Search configuration for sandbox recreation."); + const braveApiKey = await ensureValidatedBraveSearchCredential(); + nextWebSearchConfig = braveApiKey ? { fetchEnabled: true } : null; + if (nextWebSearchConfig) { + note(" [resume] Reusing Brave Search configuration."); + } + } else { + nextWebSearchConfig = await configureWebSearch(null); + } const enabledChannels = await setupMessagingChannels(); - startRecordedStep("sandbox", { sandboxName, provider, model }); sandboxName = await createSandbox( gpu, @@ -4076,11 +4078,18 @@ async function onboard(opts = {}) { provider, preferredInferenceApi, sandboxName, - webSearchConfig, + nextWebSearchConfig, enabledChannels, fromDockerfile, ); - onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); + webSearchConfig = nextWebSearchConfig; + onboardSession.markStepComplete("sandbox", { + sandboxName, + provider, + model, + nimContainer, + webSearchConfig, + }); } const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); @@ -4151,8 +4160,10 @@ module.exports = { compactText, copyBuildContextDir, classifySandboxCreateFailure, + configureWebSearch, createSandbox, formatEnvAssignment, + ensureValidatedBraveSearchCredential, getFutureShellPathHint, getGatewayStartEnv, getGatewayReuseState, diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 6052bf4eb..37639e066 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -17,9 +17,8 @@ if (dockerHost) { * Run a shell command via bash, streaming stdout/stderr (redacted) to the terminal. * Exits the process on failure unless opts.ignoreError is true. */ -function run(cmd, opts = {}) { - const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; - const result = spawnSync("bash", ["-c", cmd], { +function spawnAndHandle(file, args, opts = {}, stdio, renderedCommand) { + const result = spawnSync(file, args, { ...opts, stdio, cwd: ROOT, @@ -29,32 +28,40 @@ function run(cmd, opts = {}) { writeRedactedResult(result, stdio); } if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${redact(cmd).slice(0, 80)}`); + console.error( + ` Command failed (exit ${result.status}): ${redact(renderedCommand).slice(0, 80)}`, + ); process.exit(result.status || 1); } return result; } +function run(cmd, opts = {}) { + const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; + return spawnAndHandle("bash", ["-c", cmd], opts, stdio, cmd); +} + /** * Run a shell command interactively (stdin inherited) while capturing and redacting stdout/stderr. * Exits the process on failure unless opts.ignoreError is true. */ function runInteractive(cmd, opts = {}) { const stdio = opts.stdio ?? ["inherit", "pipe", "pipe"]; - const result = spawnSync("bash", ["-c", cmd], { - ...opts, - stdio, - cwd: ROOT, - env: { ...process.env, ...opts.env }, - }); - if (!opts.suppressOutput) { - writeRedactedResult(result, stdio); - } - if (result.status !== 0 && !opts.ignoreError) { - console.error(` Command failed (exit ${result.status}): ${redact(cmd).slice(0, 80)}`); - process.exit(result.status || 1); + return spawnAndHandle("bash", ["-c", cmd], opts, stdio, cmd); +} + +/** + * Run a program directly with argv-style arguments, bypassing shell parsing. + * Exits the process on failure unless opts.ignoreError is true. + */ +function runFile(file, args = [], opts = {}) { + if (opts.shell) { + throw new Error("runFile does not allow opts.shell=true"); } - return result; + const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; + const normalizedArgs = args.map((arg) => String(arg)); + const rendered = [shellQuote(file), ...normalizedArgs.map((arg) => shellQuote(arg))].join(" "); + return spawnAndHandle(file, normalizedArgs, { ...opts, shell: false }, stdio, rendered); } /** @@ -200,6 +207,7 @@ module.exports = { redact, run, runCapture, + runFile, runInteractive, shellQuote, validateName, diff --git a/bin/lib/sandbox-names.js b/bin/lib/sandbox-names.js new file mode 100644 index 000000000..79640232b --- /dev/null +++ b/bin/lib/sandbox-names.js @@ -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} '.`, + ); + } + return validName; +} + +module.exports = { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS, validateSandboxName }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index e7cfe8d58..e2dd714a3 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -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"); @@ -49,8 +49,9 @@ const { versionGte, } = require("../dist/lib/openshell"); const { listSandboxesCommand, showStatusCommand } = require("../dist/lib/inventory-commands"); +const { RESERVED_SANDBOX_NAMES, SANDBOX_ACTIONS } = require("./lib/sandbox-names"); const { executeDeploy } = require("../dist/lib/deploy"); -const { runStartCommand, runStopCommand } = require("../dist/lib/services-command"); +const { runStopCommand } = require("../dist/lib/services-command"); const { buildVersionedUninstallUrl, runUninstallCommand, @@ -58,23 +59,7 @@ const { // ── 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 = buildVersionedUninstallUrl(getVersion()); let OPENSHELL_BIN = null; @@ -824,12 +809,62 @@ 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"); - await runStartCommand({ - listSandboxes: () => registry.listSandboxes(), - startAll, - }); + 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() { @@ -840,6 +875,67 @@ function 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": { + 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"); runDebugCommand(args, { @@ -1165,6 +1261,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 @@ -1176,9 +1273,10 @@ function help() { nemoclaw deploy 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 @@ -1200,7 +1298,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 () => { @@ -1211,7 +1311,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); @@ -1226,7 +1337,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 fd7fa8379..0ba4ab62d 100644 --- a/docs/deployment/deploy-to-remote-gpu.md +++ b/docs/deployment/deploy-to-remote-gpu.md @@ -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 ` 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. diff --git a/docs/deployment/set-up-telegram-bridge.md b/docs/deployment/set-up-telegram-bridge.md index f3e2e08c8..6e9ac3bab 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -85,6 +85,64 @@ It does not affect Telegram connectivity. $ nemoclaw start ``` +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 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 + +Check that the Telegram bridge is running: + +```console +$ nemoclaw status +``` + +The output shows the status of all auxiliary services. + +## Send a Message + +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. + +## Allow Telegram Chats by Chat ID + +Save the Telegram chat IDs allowed to interact with the agent: + +```console +$ 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: + +```console +$ nemoclaw stop +``` + ## Related Topics - [Deploy NemoClaw to a Remote GPU Instance](deploy-to-remote-gpu.md) for remote deployment with messaging. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 4852806ea..a68f947b5 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -186,8 +186,8 @@ The following environment variables configure optional services and local access | Variable | Purpose | |---|---| -| `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | -| `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | +| `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. | +| `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. | | `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 19fc34f76..73e2b2760 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -222,6 +222,32 @@ Start optional host auxiliary services. This is the cloudflared tunnel when `clo $ nemoclaw start ``` +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. + +| 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 +$ nemoclaw telegram clear +$ nemoclaw telegram discover +``` + ### `nemoclaw stop` Stop host auxiliary services started by `nemoclaw start` (for example cloudflared). diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 7c3d40479..eacf8bc76 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -243,6 +243,18 @@ The status command detects the sandbox context and reports "active (inside sandb Run `openshell sandbox list` on the host to check the underlying sandbox state. +### `openclaw update` hangs or times out inside the sandbox + +This is expected for the current NemoClaw deployment model. +NemoClaw installs `openclaw` into the sandbox image at build time, so the CLI is image-pinned rather than updated in place inside a running sandbox. + +Do not run `openclaw update` inside the sandbox. +Instead: + +1. Upgrade to a NemoClaw release that includes the newer `openclaw` version. +2. If you build NemoClaw from source, bump the pinned `openclaw` version in `Dockerfile.base` and rebuild the sandbox base image. +3. Back up any workspace files you need, then recreate the sandbox so it uses the rebuilt image. + ### Inference requests time out Verify that the inference provider endpoint is reachable from the host. diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 6aeeb0389..83909c0aa 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -120,9 +120,58 @@ 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." + + # WSL2 ships with broken IPv6 routing. Node.js resolves dual-stack DNS results + # and tries IPv6 first (ENETUNREACH) then IPv4 (ETIMEDOUT), causing bridge + # connections to api.telegram.org and gateway.discord.gg to fail from the host. + # Force IPv4-first DNS result ordering for all bridge Node.js processes. + if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first" + info "WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes" + fi + + # Verify sandbox is running + if command -v openshell >/dev/null 2>&1; then + if ! openshell sandbox list 2>&1 | grep -q "Ready"; then + warn "No sandbox in Ready state. Telegram bridge may not work until sandbox is running." + fi + fi + mkdir -p "$PIDDIR" - # cloudflared tunnel + # Telegram bridge (only if token provided) + 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 if command -v cloudflared >/dev/null 2>&1; then start_service cloudflared \ cloudflared tunnel --url "http://localhost:$DASHBOARD_PORT" @@ -158,7 +207,25 @@ do_start() { printf " │ Public URL: %-40s│\n" "$tunnel_url" fi - echo " │ Messaging: via OpenClaw native channels (if configured) │" + if is_running telegram-bridge; then + if [ "${NEMOCLAW_TELEGRAM_DISCOVERY:-0}" = "1" ] && [ -z "${ALLOWED_CHAT_IDS:-}" ]; then + echo " │ Telegram: discovery only │" + else + echo " │ Telegram: bridge running │" + fi + else + 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 " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘" diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js new file mode 100755 index 000000000..8089d1641 --- /dev/null +++ b/scripts/telegram-bridge.js @@ -0,0 +1,328 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Telegram → NemoClaw bridge. + * + * Messages from Telegram are forwarded to the OpenClaw agent running + * inside the sandbox. When the agent needs external access, the + * OpenShell TUI lights up for approval. Responses go back to Telegram. + * + * Env: + * 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 + * NEMOCLAW_TELEGRAM_DISCOVERY=1 — reply with the sender chat ID instead of forwarding + */ + +const https = require("https"); +const { execFileSync, spawn } = require("child_process"); +const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); +const { shellQuote, validateName } = require("../bin/lib/runner"); +const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); + +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; +const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; +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 = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS); +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 + +const COOLDOWN_MS = 5000; +const lastMessageTime = new Map(); +const busyChats = new Set(); + +// ── Telegram API helpers ────────────────────────────────────────── + +function tgApi(method, body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = https.request( + { + hostname: "api.telegram.org", + path: `/bot${TOKEN}/${method}`, + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, + }, + (res) => { + let buf = ""; + res.on("data", (c) => (buf += c)); + res.on("end", () => { + try { + resolve(JSON.parse(buf)); + } catch { + resolve({ ok: false, error: buf }); + } + }); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +async function sendMessage(chatId, text, replyTo) { + // Telegram max message length is 4096 + const chunks = []; + for (let i = 0; i < text.length; i += 4000) { + chunks.push(text.slice(i, i + 4000)); + } + for (const chunk of chunks) { + await tgApi("sendMessage", { + chat_id: chatId, + text: chunk, + reply_to_message_id: replyTo, + parse_mode: "Markdown", + }).catch(() => + // Retry without markdown if it fails (unbalanced formatting) + tgApi("sendMessage", { chat_id: chatId, text: chunk, reply_to_message_id: replyTo }), + ); + } +} + +async function sendTyping(chatId) { + await tgApi("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}); +} + +// ── Run agent inside sandbox ────────────────────────────────────── + +function runAgentInSandbox(message, sessionId) { + return new Promise((resolve) => { + 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-"); + const confPath = `${confDir}/config`; + require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 }); + + // Pass message and API key via stdin to avoid shell interpolation. + // The remote command reads them from environment/stdin rather than + // embedding user content in a shell string. + const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9-]/g, ""); + const cmd = `export NVIDIA_API_KEY=${shellQuote(API_KEY)} && nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`; + + const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { + timeout: 120000, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + + proc.on("close", (code) => { + try { + require("fs").unlinkSync(confPath); + require("fs").rmdirSync(confDir); + } catch { + /* ignored */ + } + + // Extract the actual agent response — skip setup lines + const lines = stdout.split("\n"); + const responseLines = lines.filter( + (l) => + !l.startsWith("Setting up NemoClaw") && + !l.startsWith("[plugins]") && + !l.startsWith("(node:") && + !l.includes("NemoClaw ready") && + !l.includes("NemoClaw registered") && + !l.includes("openclaw agent") && + !l.includes("┌─") && + !l.includes("│ ") && + !l.includes("└─") && + l.trim() !== "", + ); + + const response = responseLines.join("\n").trim(); + + if (response) { + resolve(response); + } else if (code !== 0) { + resolve(`Agent exited with code ${code}. ${stderr.trim().slice(0, 500)}`); + } else { + resolve("(no response)"); + } + }); + + proc.on("error", (err) => { + resolve(`Error: ${err.message}`); + }); + }); +} + +// ── Poll loop ───────────────────────────────────────────────────── + +async function poll() { + try { + const res = await tgApi("getUpdates", { offset, timeout: 30 }); + + if (res.ok && res.result?.length > 0) { + for (const update of res.result) { + offset = update.update_id + 1; + + const msg = update.message; + if (!msg?.text) continue; + + const chatId = String(msg.chat.id); + + // Access control + if (!DISCOVERY_ONLY && !isChatAllowed(ALLOWED_CHATS, 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( + chatId, + "🦀 *NemoClaw* — powered by Nemotron 3 Super 120B\n\n" + + "Send me a message and I'll run it through the OpenClaw agent " + + "inside an OpenShell sandbox.\n\n" + + "If the agent needs external access, the TUI will prompt for approval.", + msg.message_id, + ); + continue; + } + + // Handle /reset + if (msg.text === "/reset") { + activeSessions.delete(chatId); + await sendMessage(chatId, "Session reset.", msg.message_id); + continue; + } + + // Rate limiting: per-chat cooldown + const now = Date.now(); + 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, + ); + continue; + } + + // Per-chat serialization: reject if this chat already has an active session + if (busyChats.has(chatId)) { + await sendMessage(chatId, "Still processing your previous message.", msg.message_id); + continue; + } + + lastMessageTime.set(chatId, now); + busyChats.add(chatId); + + // Send typing indicator + await sendTyping(chatId); + + // Keep a typing indicator going while agent runs + const typingInterval = setInterval(() => sendTyping(chatId), 4000); + + try { + const response = await runAgentInSandbox(msg.text, chatId); + clearInterval(typingInterval); + console.log(`[${chatId}] agent: ${response.slice(0, 100)}...`); + await sendMessage(chatId, response, msg.message_id); + } catch (err) { + clearInterval(typingInterval); + await sendMessage(chatId, `Error: ${err.message}`, msg.message_id); + } finally { + busyChats.delete(chatId); + } + } + } + } catch (err) { + console.error("Poll error:", err.message); + } + + // Continue polling (1s floor prevents tight-loop resource waste) + setTimeout(poll, 1000); +} + +// ── Main ────────────────────────────────────────────────────────── + +async function main() { + const me = await tgApi("getMe", {}); + if (!me.ok) { + console.error("Failed to connect to Telegram:", JSON.stringify(me)); + process.exit(1); + } + + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Telegram Bridge │"); + console.log(" │ │"); + console.log(` │ Bot: @${(me.result.username + " ").slice(0, 37)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); + console.log(" │ │"); + 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(""); + + poll(); +} + +main(); diff --git a/src/lib/deploy.test.ts b/src/lib/deploy.test.ts index bcfa78ac6..f0e3b170f 100644 --- a/src/lib/deploy.test.ts +++ b/src/lib/deploy.test.ts @@ -60,6 +60,26 @@ describe("buildDeployEnvLines", () => { expect(envLines).toContain("NEMOCLAW_POLICY_MODE='suggested'"); expect(envLines).toContain("NVIDIA_API_KEY='nvapi-test'"); }); + + it("propagates Telegram allowlist settings to remote deploy env", () => { + const envLines = buildDeployEnvLines({ + env: { + NEMOCLAW_TELEGRAM_DISCOVERY: "1", + }, + sandboxName: "my-assistant", + provider: "build", + credentials: { + NVIDIA_API_KEY: "nvapi-test", + ALLOWED_CHAT_IDS: "111,222", + TELEGRAM_BOT_TOKEN: "123456:abc", + }, + shellQuote: (value: string) => `'${value}'`, + }); + + expect(envLines).toContain("ALLOWED_CHAT_IDS='111,222'"); + expect(envLines).toContain("NEMOCLAW_TELEGRAM_DISCOVERY='1'"); + expect(envLines).toContain("TELEGRAM_BOT_TOKEN='123456:abc'"); + }); }); describe("Brev status helpers", () => { diff --git a/src/lib/deploy.ts b/src/lib/deploy.ts index 1d5ba2746..caa6592de 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy.ts @@ -13,6 +13,7 @@ export interface DeployCredentials { COMPATIBLE_API_KEY?: string | null; COMPATIBLE_ANTHROPIC_API_KEY?: string | null; GITHUB_TOKEN?: string | null; + ALLOWED_CHAT_IDS?: string | null; TELEGRAM_BOT_TOKEN?: string | null; DISCORD_BOT_TOKEN?: string | null; SLACK_BOT_TOKEN?: string | null; @@ -124,6 +125,7 @@ export function buildDeployEnvLines(opts: { "NEMOCLAW_ENDPOINT_URL", "NEMOCLAW_POLICY_MODE", "NEMOCLAW_POLICY_PRESETS", + "NEMOCLAW_TELEGRAM_DISCOVERY", "CHAT_UI_URL", ] as const; for (const key of passthroughVars) { @@ -251,6 +253,7 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise COMPATIBLE_API_KEY: getCredential("COMPATIBLE_API_KEY"), COMPATIBLE_ANTHROPIC_API_KEY: getCredential("COMPATIBLE_ANTHROPIC_API_KEY"), GITHUB_TOKEN: getCredential("GITHUB_TOKEN"), + ALLOWED_CHAT_IDS: getCredential("ALLOWED_CHAT_IDS"), TELEGRAM_BOT_TOKEN: getCredential("TELEGRAM_BOT_TOKEN"), DISCORD_BOT_TOKEN: getCredential("DISCORD_BOT_TOKEN"), SLACK_BOT_TOKEN: getCredential("SLACK_BOT_TOKEN"), diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 6156a574e..27691356c 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -120,6 +120,20 @@ describe("onboard session", () => { expect(loaded.metadata.token).toBeUndefined(); }); + it("persists and clears web search config through safe session updates", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + webSearchConfig: { fetchEnabled: true }, + }); + + let loaded = session.loadSession(); + expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true }); + + session.completeSession({ webSearchConfig: null }); + loaded = session.loadSession(); + expect(loaded.webSearchConfig).toBeNull(); + }); + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { session.saveSession(session.createSession({ metadata: { gatewayName: "nemoclaw" } })); session.markStepComplete("provider_selection", { diff --git a/test/cli.test.js b/test/cli.test.js index 736fb58f1..ef9efffa9 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -15,7 +15,7 @@ function run(args) { function runWithEnv(args, env = {}, timeout = 10000) { try { - const out = execSync(`node "${CLI}" ${args}`, { + const out = execSync(`${JSON.stringify(process.execPath)} "${CLI}" ${args}`, { encoding: "utf-8", timeout, env: { @@ -32,6 +32,52 @@ 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 }, + ); +} + +function writeNodeCaptureStub(localBin, markerFile) { + fs.writeFileSync( + path.join(localBin, "node"), + [ + "#!/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 }, + ); +} + +function readFileEventually(filePath, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, "utf8"); + if (content.includes("ARGS=")) { + return content; + } + } + } + return fs.readFileSync(filePath, "utf8"); +} + describe("CLI dispatch", () => { it("help exits 0 and shows sections", () => { const r = run("help"); @@ -40,6 +86,8 @@ describe("CLI dispatch", () => { expect(r.out.includes("Sandbox Management")).toBeTruthy(); expect(r.out.includes("Policy Presets")).toBeTruthy(); expect(r.out.includes("Compatibility Commands")).toBeTruthy(); + expect(r.out.includes("nemoclaw telegram [help]")).toBeTruthy(); + expect(r.out.includes("nemoclaw -- ")).toBeTruthy(); }); it("--help exits 0", () => { @@ -92,8 +140,162 @@ describe("CLI dispatch", () => { }), { mode: 0o600 }, ); + writeBashCaptureStub(localBin, markerFile); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "", + ALLOWED_CHAT_IDS: "", + }); + + expect(r.code).toBe(0); + expect(r.out).not.toContain("NVIDIA API Key required"); + // Services module now runs in-process (no bash shelling) + expect(r.out).toContain("NemoClaw Services"); + }); + + 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(localBin, "bash"), + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + writeNodeCaptureStub(localBin, markerFile); + + expect(runWithEnv("telegram allow 12345,67890", { HOME: home }).code).toBe(0); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + TELEGRAM_BOT_TOKEN: "telegram-token", + }); + + expect(r.code).toBe(0); + const marker = readFileEventually(markerFile); + expect(marker).toContain("SANDBOX_NAME=alpha"); + expect(marker).toContain("ALLOWED_CHAT_IDS=12345,67890"); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=0"); + expect(marker).toContain("scripts/telegram-bridge.js"); + }); + + 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(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + writeNodeCaptureStub(localBin, markerFile); + + const r = runWithEnv("start", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + TELEGRAM_BOT_TOKEN: "telegram-token", + }); + + expect(r.code).toBe(0); + const marker = readFileEventually(markerFile); + expect(marker).toContain("ALLOWED_CHAT_IDS="); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=0"); + expect(marker).toContain("scripts/telegram-bridge.js"); + }); + + 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 }); + writeNodeCaptureStub(localBin, markerFile); + + const r = runWithEnv("start --discover-chat-id", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "nvapi-test", + TELEGRAM_BOT_TOKEN: "telegram-token", + }); + + expect(r.code).toBe(0); + const marker = readFileEventually(markerFile); + expect(marker).toContain("NEMOCLAW_TELEGRAM_DISCOVERY=1"); + expect(marker).toContain("scripts/telegram-bridge.js"); + }); + + 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"); + }); + + 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)}`, @@ -103,17 +305,16 @@ describe("CLI dispatch", () => { { mode: 0o755 }, ); - const r = runWithEnv("start", { + const r = runWithEnv("-- telegram connect", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, - NVIDIA_API_KEY: "", - TELEGRAM_BOT_TOKEN: "", }); expect(r.code).toBe(0); - expect(r.out).not.toContain("NVIDIA API Key required"); - // Services module now runs in-process (no bash shelling) - expect(r.out).toContain("NemoClaw Services"); + const marker = fs.readFileSync(markerFile, "utf8"); + expect(marker).toContain("sandbox"); + expect(marker).toContain("connect"); + expect(marker).toContain("telegram"); }); it("unknown onboard option exits 1", () => { diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index a1eb6726b..29bdfc537 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -102,9 +102,19 @@ else fail "config hash mismatch: $OUT" fi -# ── Test 5: Config hash is not writable by sandbox ─────────────── +# ── Test 5: Update hints are disabled in sandbox config ────────── -info "5. Config hash not writable by sandbox user" +info "5. Sandbox config disables startup update hints" +OUT=$(run_as_root "python3 -c 'import json; cfg=json.load(open(\"/sandbox/.openclaw/openclaw.json\")); print(\"OK\" if cfg.get(\"update\", {}).get(\"checkOnStart\") is False else \"BAD\")'") +if echo "$OUT" | grep -q "OK"; then + pass "startup update hints disabled" +else + fail "startup update hints not disabled: $OUT" +fi + +# ── Test 6: Config hash is not writable by sandbox ─────────────── + +info "6. Config hash not writable by sandbox user" OUT=$(run_as_sandbox "echo fake > /sandbox/.openclaw/.config-hash 2>&1 || echo BLOCKED") if echo "$OUT" | grep -q "BLOCKED\|Permission denied"; then pass "sandbox cannot tamper with config hash" @@ -112,9 +122,9 @@ else fail "sandbox CAN write to config hash: $OUT" fi -# ── Test 6: gosu is installed ──────────────────────────────────── +# ── Test 7: gosu is installed ──────────────────────────────────── -info "6. gosu binary is available" +info "7. gosu binary is available" OUT=$(run_as_root "command -v gosu && gosu --version") if echo "$OUT" | grep -q "gosu"; then pass "gosu installed" @@ -122,9 +132,9 @@ else fail "gosu not found: $OUT" fi -# ── Test 7: Entrypoint PATH is locked to system dirs ───────────── +# ── Test 8: Entrypoint PATH is locked to system dirs ───────────── -info "7. Entrypoint locks PATH to system directories" +info "8. Entrypoint locks PATH to system directories" # Walk the entrypoint line-by-line, eval only export lines, stop after PATH. OUT=$(run_as_root "bash -c 'while IFS= read -r line; do case \"\$line\" in export\\ *) eval \"\$line\" 2>/dev/null;; esac; case \"\$line\" in \"export PATH=\"*) break;; esac; done < /usr/local/bin/nemoclaw-start; echo \$PATH'") if echo "$OUT" | grep -q "^/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin$"; then @@ -133,9 +143,9 @@ else fail "PATH not locked as expected: $OUT" fi -# ── Test 8: openclaw resolves to expected absolute path ────────── +# ── Test 9: openclaw resolves to expected absolute path ────────── -info "8. Gateway runs the expected openclaw binary" +info "9. Gateway runs the expected openclaw binary" OUT=$(run_as_root "gosu gateway which openclaw") if [ "$OUT" = "/usr/local/bin/openclaw" ]; then pass "openclaw resolves to /usr/local/bin/openclaw" @@ -143,9 +153,9 @@ else fail "openclaw resolves to unexpected path: $OUT" fi -# ── Test 9: Symlinks point to expected targets ─────────────────── +# ── Test 10: Symlinks point to expected targets ────────────────── -info "9. All .openclaw symlinks point to .openclaw-data" +info "10. All .openclaw symlinks point to .openclaw-data" FAILED_LINKS="" for link in agents extensions workspace skills hooks identity devices canvas cron; do OUT=$(run_as_root "readlink -f /sandbox/.openclaw/$link") @@ -159,9 +169,9 @@ else fail "symlink targets wrong:$FAILED_LINKS" fi -# ── Test 10: iptables is installed (required for network policy enforcement) ── +# ── Test 11: iptables is installed (required for network policy enforcement) ── -info "10. iptables is installed" +info "11. iptables is installed" OUT=$(run_as_root "iptables --version 2>&1") if echo "$OUT" | grep -q "iptables v"; then pass "iptables installed: $OUT" @@ -180,7 +190,6 @@ else fi # ── Test 12: Sandbox user cannot kill gateway-user processes ───── - info "12. Sandbox user cannot kill gateway-user processes" # Start a dummy process as gateway, try to kill it as sandbox OUT=$(docker run --rm --entrypoint "" "$IMAGE" bash -c ' diff --git a/test/onboard.test.js b/test/onboard.test.js index bfe4fa10f..0248f75f2 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -43,6 +44,8 @@ import { import { stageOptimizedSandboxBuildContext } from "../bin/lib/sandbox-build-context"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +const require = createRequire(import.meta.url); + describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( @@ -1300,7 +1303,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*\);/, ); }); @@ -1480,13 +1483,28 @@ const { EventEmitter } = require("node:events"); const commands = []; runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); return { status: 0 }; }; runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -1520,15 +1538,22 @@ const { createSandbox } = require(${onboardPath}); `; fs.writeFileSync(scriptPath, script); + /** @type {NodeJS.ProcessEnv} */ + const childEnv = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }; + delete childEnv.NODE_OPTIONS; + for (const key of Object.keys(childEnv)) { + if (key.startsWith("VITEST")) delete childEnv[key]; + } + const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, + env: childEnv, }); assert.equal(result.status, 0, result.stderr); @@ -1542,7 +1567,7 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); assert.equal(payload.sandboxName, "my-assistant"); const createCommand = payload.commands.find((entry) => - entry.command.includes("'sandbox' 'create'"), + entry.command?.includes("'sandbox' 'create'"), ); assert.ok(createCommand, "expected sandbox create command"); assert.match(createCommand.command, /'nemoclaw-start'/); @@ -1553,10 +1578,232 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.ok( payload.commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), + entry.command?.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), ), "expected default loopback dashboard forward", ); + assert.ok( + payload.commands.some((entry) => + entry.command?.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ), + ), + "expected dashboard readiness check via quoted OpenShell argv helper", + ); + assert.ok( + payload.commands.some( + (entry) => + entry.type === "runFile" && + entry.file === "bash" && + entry.args[0].endsWith(`${path.sep}scripts${path.sep}setup-dns-proxy.sh`) && + entry.args[1] === "nemoclaw" && + entry.args[2] === "my-assistant", + ), + "expected DNS proxy setup to run via argv-style helper", + ); + }); + + it("rejects an invalid sandboxNameOverride before any shell helpers run", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-invalid-override-")); + const scriptPath = path.join(tmpDir, "invalid-sandbox-override.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + try { + await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "bad;name"); + console.log(JSON.stringify({ ok: true, commands })); + } catch (error) { + console.log(JSON.stringify({ ok: false, message: error.message, commands })); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.ok, false); + assert.match(payload.message, /Invalid sandbox name/); + assert.deepEqual(payload.commands, []); + }); + + it("rejects an empty sandboxNameOverride before any shell helpers run", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-empty-override-")); + const scriptPath = path.join(tmpDir, "empty-sandbox-override.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => { + throw new Error("prompt should not be reached for an explicit override"); +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + try { + await createSandbox(null, "gpt-5.4", "nvidia-prod", null, ""); + console.log(JSON.stringify({ ok: true, commands })); + } catch (error) { + console.log(JSON.stringify({ ok: false, message: error.message, commands })); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.ok, false); + assert.match(payload.message, /sandbox name is required/); + assert.deepEqual(payload.commands, []); + }); + + it("rejects a whitespace-only sandboxNameOverride before any shell helpers run", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-blank-override-")); + const scriptPath = path.join(tmpDir, "blank-sandbox-override.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => { + throw new Error("prompt should not be reached for an explicit override"); +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + try { + await createSandbox(null, "gpt-5.4", "nvidia-prod", null, " "); + console.log(JSON.stringify({ ok: true, commands })); + } catch (error) { + console.log(JSON.stringify({ ok: false, message: error.message, commands })); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.ok, false); + assert.match(payload.message, /(Invalid sandbox name|sandbox name is required)/); + assert.deepEqual(payload.commands, []); }); it("binds the dashboard forward to 0.0.0.0 when CHAT_UI_URL points to a remote host", async () => { @@ -1585,13 +1832,28 @@ const { EventEmitter } = require("node:events"); const commands = []; runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); return { status: 0 }; }; runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -2310,16 +2572,31 @@ const commands = []; let sandboxListCalls = 0; const keepAlive = setInterval(() => {}, 1000); runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); return { status: 0 }; }; runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) { sandboxListCalls += 1; return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; } - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -2422,10 +2699,15 @@ const registry = require(${registryPath}); const commands = []; runner.run = (command, opts = {}) => { - commands.push({ command, env: opts.env || null }); + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); return { status: 0 }; }; runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; @@ -2473,6 +2755,59 @@ const { createSandbox } = require(${onboardPath}); ); }); + it("persists Brave config only after sandbox recreation succeeds", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + + assert.doesNotMatch(source, /current\.webSearchConfig\s*=/); + assert.match( + source, + /let nextWebSearchConfig = webSearchConfig;[\s\S]*sandboxName = await createSandbox\([\s\S]*nextWebSearchConfig,[\s\S]*\);\s*webSearchConfig = nextWebSearchConfig;\s*onboardSession\.markStepComplete\("sandbox", \{[\s\S]*webSearchConfig,/, + ); + assert.match( + source, + /if \(resumeSandbox\) {\s*if \(webSearchConfig\) {\s*note\(" {2}\[resume\] Reusing Brave Search configuration already baked into the sandbox\."\);\s*}\s*skippedStepMessage\("sandbox", sandboxName\);/, + ); + }); + + it("fails before prompting when Brave validation is required in non-interactive mode", async () => { + const onboardPath = path.join(import.meta.dirname, "..", "bin", "lib", "onboard"); + const credentialsPath = path.join(import.meta.dirname, "..", "bin", "lib", "credentials"); + const credentials = require(credentialsPath); + const originalPrompt = credentials.prompt; + const originalGetCredential = credentials.getCredential; + const originalEnv = process.env.BRAVE_API_KEY; + let promptCalls = 0; + + try { + credentials.prompt = async () => { + promptCalls += 1; + throw new Error("prompt should not run"); + }; + credentials.getCredential = () => null; + delete process.env.BRAVE_API_KEY; + delete require.cache[require.resolve(onboardPath)]; + const { ensureValidatedBraveSearchCredential } = require(onboardPath); + + await assert.rejects( + () => ensureValidatedBraveSearchCredential(true), + /Brave Search requires BRAVE_API_KEY or a saved Brave Search credential in non-interactive mode\./, + ); + assert.equal(promptCalls, 0); + } finally { + credentials.prompt = originalPrompt; + credentials.getCredential = originalGetCredential; + if (originalEnv === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = originalEnv; + } + delete require.cache[require.resolve(onboardPath)]; + } + }); + it("prints resume guidance when sandbox image upload times out", () => { const errors = []; const originalError = console.error; @@ -2700,7 +3035,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -2827,7 +3168,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -3085,7 +3432,13 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; - if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if ( + command.includes( + "'sandbox' 'exec' 'my-assistant' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; @@ -3129,15 +3482,22 @@ const { createSandbox } = require(${onboardPath}); `; fs.writeFileSync(scriptPath, script); + /** @type {NodeJS.ProcessEnv} */ + const childEnv = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }; + delete childEnv.NODE_OPTIONS; + for (const key of Object.keys(childEnv)) { + if (key.startsWith("VITEST")) delete childEnv[key]; + } + const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${fakeBin}:${process.env.PATH || ""}`, - NEMOCLAW_NON_INTERACTIVE: "1", - }, + env: childEnv, }); assert.equal(result.status, 0, result.stderr); @@ -3155,7 +3515,7 @@ const { createSandbox } = require(${onboardPath}); true, "extra.txt from custom build context should be staged", ); - }); + }, 35000); it("exits with an error when the --from Dockerfile path does not exist", async () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -3236,4 +3596,127 @@ const { createSandbox } = require(${onboardPath}); assert.match(fnBody, /isNonInteractive\(\)/); assert.match(fnBody, /process\.exit\(1\)/); }); + + it("re-prompts on an overlength sandbox name and uses the validated retry value", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-reprompt-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-reprompt.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +const prompts = []; +const answers = [${JSON.stringify("a".repeat(64))}, "valid-name"]; + +runner.run = (command, opts = {}) => { + commands.push({ type: "run", command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runFile = (file, args = [], opts = {}) => { + commands.push({ type: "runFile", file, args, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + commands.push({ type: "runCapture", command }); + if (command.includes("'sandbox' 'get' 'valid-name'")) return ""; + if (command.includes("'sandbox' 'list'")) return "valid-name Ready"; + if ( + command.includes( + "'sandbox' 'exec' 'valid-name' 'curl' '-sf' 'http://localhost:18789/'", + ) + ) { + return "ok"; + } + if (command.includes("'forward' 'list'")) return "18789 -> valid-name:18789"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async (question) => { + prompts.push(question); + return answers.shift() || ""; +}; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ type: "spawn", command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: valid-name\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify({ sandboxName, prompts, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /sandbox name too long/); + assert.match(result.stderr, /Please try again/); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + assert.equal(payload.sandboxName, "valid-name"); + assert.equal(payload.prompts.length, 2); + assert.ok( + payload.commands.some((entry) => entry.command?.includes("'sandbox' 'get' 'valid-name'")), + "expected sandbox lookup helpers to use the validated retry value", + ); + assert.ok( + payload.commands.some( + (entry) => + entry.type === "runFile" && entry.file === "bash" && entry.args[2] === "valid-name", + ), + "expected DNS setup to use the validated retry value", + ); + assert.ok( + payload.commands.every((entry) => !entry.command?.includes("a".repeat(64))), + "expected invalid overlength name to be rejected before shell helpers run", + ); + }); }); diff --git a/test/registry.test.js b/test/registry.test.js index 0f6b1eac2..5a63c81f1 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -107,6 +107,25 @@ describe("registry", () => { expect(data.defaultSandbox).toBe("persist"); }); + it("clearAll removes persisted sandboxes and the default pointer", () => { + registry.registerSandbox({ name: "alpha", model: "m1" }); + registry.registerSandbox({ name: "beta", model: "m2" }); + registry.setDefault("beta"); + + registry.clearAll(); + + expect(registry.listSandboxes()).toEqual({ + sandboxes: [], + defaultSandbox: null, + }); + expect(registry.getDefault()).toBe(null); + expect(registry.getSandbox("alpha")).toBe(null); + expect(JSON.parse(fs.readFileSync(regFile, "utf-8"))).toEqual({ + sandboxes: {}, + defaultSandbox: null, + }); + }); + it("handles corrupt registry file gracefully", () => { fs.mkdirSync(path.dirname(regFile), { recursive: true }); fs.writeFileSync(regFile, "NOT JSON"); diff --git a/test/runner.test.js b/test/runner.test.js index eb9519fd5..28250b849 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -11,6 +11,7 @@ import { describe, expect, it, vi } 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", () => { @@ -56,6 +57,62 @@ describe("runner helpers", () => { expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]); expect(calls[1][2].stdio).toEqual(["inherit", "pipe", "pipe"]); }); + it("runs argv-style commands without going through bash -c", () => { + const calls = []; + const originalSpawnSync = childProcess.spawnSync; + // @ts-expect-error — intentional partial mock for testing + childProcess.spawnSync = (...args) => { + calls.push(args); + return { status: 0, stdout: "", stderr: "" }; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runFile } = require(runnerPath); + runFile("bash", ["/tmp/setup.sh", "safe;name", "$(id)"]); + } finally { + childProcess.spawnSync = originalSpawnSync; + delete require.cache[require.resolve(runnerPath)]; + } + + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("bash"); + expect(calls[0][1]).toEqual(["/tmp/setup.sh", "safe;name", "$(id)"]); + expect(calls[0][2].shell).toBe(false); + expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]); + }); + + it("rejects opts.shell for argv-style commands", () => { + const { runFile } = require(runnerPath); + expect(() => runFile("bash", ["/tmp/setup.sh"], { shell: true })).toThrow( + /runFile does not allow opts\.shell=true/, + ); + }); + it("honors suppressOutput for argv-style commands", () => { + const originalSpawnSync = childProcess.spawnSync; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + // @ts-expect-error — intentional partial mock for testing + childProcess.spawnSync = () => ({ + status: 0, + stdout: "safe stdout\n", + stderr: "safe stderr\n", + }); + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runFile } = require(runnerPath); + runFile("bash", ["/tmp/setup.sh"], { suppressOutput: true }); + } finally { + childProcess.spawnSync = originalSpawnSync; + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + delete require.cache[require.resolve(runnerPath)]; + } + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); }); describe("runner env merging", () => { @@ -107,6 +164,38 @@ describe("runner env merging", () => { expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin"); }); + + it("preserves process env when opts.env is provided to runFile", () => { + const calls = []; + const originalSpawnSync = childProcess.spawnSync; + const originalPath = process.env.PATH; + // @ts-expect-error — intentional partial mock for testing + childProcess.spawnSync = (...args) => { + calls.push(args); + return { status: 0, stdout: "", stderr: "" }; + }; + + try { + delete require.cache[require.resolve(runnerPath)]; + const { runFile } = require(runnerPath); + process.env.PATH = "/usr/local/bin:/usr/bin"; + runFile("bash", ["/tmp/setup.sh"], { + env: { OPENSHELL_CLUSTER_IMAGE: "ghcr.io/nvidia/openshell/cluster:0.0.12" }, + }); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + childProcess.spawnSync = originalSpawnSync; + delete require.cache[require.resolve(runnerPath)]; + } + + expect(calls).toHaveLength(1); + expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); + expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin"); + }); }); describe("shellQuote", () => { @@ -267,6 +356,23 @@ describe("redact", () => { }); }); +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"); + }); + + it("rejects names that do not start with a letter", () => { + const { validateSandboxName } = require(sandboxNamesPath); + expect(() => validateSandboxName("1sandbox")).toThrow(/Must start with a letter/); + }); +}); + describe("regression guards", () => { it("runCapture redacts secrets before rethrowing errors", () => { const originalExecSync = childProcess.execSync; diff --git a/test/service-env.test.js b/test/service-env.test.js index 6e811dc9e..d863333d7 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -24,8 +24,46 @@ describe("service environment", () => { }, }); - // Messaging channels are now native to OpenClaw inside the sandbox - expect(result).toContain("Messaging: via OpenClaw native channels"); + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("TELEGRAM_BOT_TOKEN not set"); + expect(result).toContain("Telegram: not started (no token)"); + }); + + it("warns and skips Telegram bridge when token is set without NVIDIA_API_KEY", () => { + const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-key-")); + const result = execFileSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + NVIDIA_API_KEY: "", + TELEGRAM_BOT_TOKEN: "test-token", + SANDBOX_NAME: "test-box", + TMPDIR: workspace, + }, + }); + + expect(result).not.toContain("NVIDIA_API_KEY required"); + expect(result).toContain("NVIDIA_API_KEY not set"); + 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)"); }); }); diff --git a/test/shellquote-sandbox.test.js b/test/shellquote-sandbox.test.js index 3bc3d7c64..13be82a7d 100644 --- a/test/shellquote-sandbox.test.js +++ b/test/shellquote-sandbox.test.js @@ -1,28 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 - -// Verify shellQuote is applied to sandboxName in shell commands +// Verify sandbox names stay validated and out of raw shell command strings. import fs from "fs"; import path from "path"; import { describe, it, expect } from "vitest"; -describe("sandboxName shell quoting in onboard.js", () => { +describe("sandboxName command hardening in onboard.js", () => { const src = fs.readFileSync( path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), "utf-8", ); - it("quotes sandboxName in openshell sandbox exec command", () => { - expect(src).toMatch(/openshell sandbox exec \$\{shellQuote\(sandboxName\)\}/); + it("re-validates sandboxName at the createSandbox boundary", () => { + expect(src).toMatch( + /const sandboxName =\s*sandboxNameOverride !== null && sandboxNameOverride !== undefined\s*\?\s*validateSandboxName\(/, + ); + }); + + it("runs setup-dns-proxy.sh through the argv helper instead of bash -c interpolation", () => { + expect(src).toMatch(/runFile\("bash",\s*\[path\.join\(SCRIPTS, "setup-dns-proxy\.sh"\),/); }); - it("quotes sandboxName in setup-dns-proxy.sh command", () => { + it("probes dashboard readiness through runCaptureOpenshell argv arguments", () => { expect(src).toMatch( - /setup-dns-proxy\.sh.*\$\{shellQuote\(GATEWAY_NAME\)\}.*\$\{shellQuote\(sandboxName\)\}/, + /runCaptureOpenshell\(\s*\[\s*"sandbox",\s*"exec",\s*sandboxName,\s*"curl",\s*"-sf",\s*`http:\/\/localhost:\$\{CONTROL_UI_PORT\}\/`\s*\]/, ); }); - it("does not have unquoted sandboxName in runCapture or run calls", () => { + it("does not have raw sandboxName interpolation in run or runCapture template literals", () => { // Match run()/runCapture() calls that span multiple lines and contain // template literals, so multiline invocations are not missed. const callPattern = /\b(run|runCapture)\s*\(\s*`([^`]*)`/g; @@ -30,10 +35,7 @@ describe("sandboxName shell quoting in onboard.js", () => { let match; while ((match = callPattern.exec(src)) !== null) { const template = match[2]; - if ( - template.includes("${sandboxName}") && - !template.includes("shellQuote(sandboxName)") - ) { + if (template.includes("${sandboxName}") && !template.includes("shellQuote(sandboxName)")) { const line = src.slice(0, match.index).split("\n").length; violations.push(`Line ${line}: ${match[0].slice(0, 120).trim()}`); }