From dda17f24bf3a30be8ff27af003fbc8cdd0a00e0e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 18:13:15 -0700 Subject: [PATCH 01/15] chore: unify credential passing pattern across all integrations Standardize how user-provided secrets are passed into the sandbox: - Add TELEGRAM_BOT_TOKEN to sandbox creation env vars (was only in deploy path, missing from onboard) - Standardize NVIDIA_API_KEY to use getCredential() like all other tokens instead of checking process.env directly - Add inline comment documenting the credential passing pattern - Add "Credential Handling" section to architecture doc with inventory table covering all six credentials and their flow All four user tokens (NVIDIA, Discord, Slack, Telegram) now follow the same getCredential() || process.env pattern at sandbox creation. Closes #616 --- bin/lib/onboard.js | 14 ++++++++++++-- docs/reference/architecture.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 2bbbda577..a106285c5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -443,9 +443,15 @@ async function createSandbox(gpu) { console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const chatUiUrl = process.env.CHAT_UI_URL || 'http://127.0.0.1:18789'; + // Pass user-provided secrets into the sandbox as environment variables. + // All tokens follow the same pattern: getCredential() checks env first, + // then ~/.nemoclaw/credentials.json. OpenClaw auto-enables channels when + // it detects the corresponding env var (e.g., DISCORD_BOT_TOKEN). + // See docs/reference/architecture.md "Credential Handling" for the full inventory. const envArgs = [`CHAT_UI_URL=${shellQuote(chatUiUrl)}`]; - if (process.env.NVIDIA_API_KEY) { - envArgs.push(`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)}`); + const apiKey = getCredential("NVIDIA_API_KEY") || process.env.NVIDIA_API_KEY; + if (apiKey) { + envArgs.push(`NVIDIA_API_KEY=${shellQuote(apiKey)}`); } const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { @@ -455,6 +461,10 @@ async function createSandbox(gpu) { if (slackToken) { envArgs.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); } + const tgToken = getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN; + if (tgToken) { + envArgs.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`); + } // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 74369d654..099f3d49a 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -100,3 +100,31 @@ Agent (sandbox) ──▶ OpenShell gateway ──▶ NVIDIA Endpoint (build ``` Refer to [Inference Profiles](../reference/inference-profiles.md) for provider configuration details. + +## Credential Handling + +All user-provided secrets follow the same pattern: + +1. Stored on the host in `~/.nemoclaw/credentials.json` (mode 0600) or as environment variables. +2. Retrieved via `getCredential(key)` which checks env vars first, then the credentials file. +3. Passed into the sandbox as environment variables at creation time. +4. Never written to `openclaw.json` (immutable at runtime). + +### Credential Inventory + +| Credential | Passed to sandbox | Used inside sandbox | Used on host | Notes | +|---|---|---|---|---| +| `NVIDIA_API_KEY` | Yes (env var) | Yes — startup script writes `auth-profiles.json` | Yes — deploy, Telegram bridge | OpenClaw requires a specific JSON file format; `nemoclaw-start.sh` handles the translation | +| `DISCORD_BOT_TOKEN` | Yes (env var) | Yes — OpenClaw reads env var directly, auto-enables Discord channel | No | | +| `SLACK_BOT_TOKEN` | Yes (env var) | Yes — OpenClaw reads env var directly, auto-enables Slack channel | No | | +| `TELEGRAM_BOT_TOKEN` | Yes (env var) | Available but unused — bridge runs on host | Yes — Telegram bridge (host-side) | Bridge uses SSH to relay messages into sandbox | +| `GITHUB_TOKEN` | Deploy path only | No | Yes — private repo access | Not needed inside sandbox | +| Gateway auth token | No — baked at build time | Yes — `openclaw.json` (root:root 444) | N/A | Per-build unique, immutable security boundary | + +### Why Telegram runs on the host + +The Telegram bridge (`scripts/telegram-bridge.js`) runs on the host and communicates with the sandbox via OpenShell SSH. This is intentional: + +- It avoids giving the sandbox direct Telegram API access beyond what the network policy allows. +- It allows the bridge to manage multiple sandboxes from one host process. +- The `TELEGRAM_BOT_TOKEN` is still passed into the sandbox for future OpenClaw channel plugin support. From 67cc12b7e7115ff8b0171fa16aefe90847c7842b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 18:54:36 -0700 Subject: [PATCH 02/15] feat: host-side bridge framework with Discord support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared sandbox relay logic from telegram-bridge.js into bridge-core.js, then build a Discord bridge on the same pattern. bridge-core.js handles: - OpenShell SSH config generation and temp file management - Agent invocation via SSH relay to sandbox - Response line filtering (strips setup boilerplate) - SANDBOX_NAME validation and NVIDIA_API_KEY requirement discord-bridge.js uses discord.js for WebSocket-based gateway connection. Messages relay through bridge-core the same way Telegram does — credentials stay on the host, never enter sandbox. telegram-bridge.js refactored to import from bridge-core with no behavior changes. start-services.sh updated to manage the Discord bridge service alongside Telegram when DISCORD_BOT_TOKEN is set. Closes #618 --- package-lock.json | 238 ++++++++++++++++++++++++++++++++++++- package.json | 1 + scripts/bridge-core.js | 121 +++++++++++++++++++ scripts/discord-bridge.js | 100 ++++++++++++++++ scripts/start-services.sh | 8 +- scripts/telegram-bridge.js | 85 +------------ test/runner.test.js | 16 ++- 7 files changed, 482 insertions(+), 87 deletions(-) create mode 100644 scripts/bridge-core.js create mode 100644 scripts/discord-bridge.js diff --git a/package-lock.json b/package-lock.json index 362db593d..ceae7fbe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, "bin": { @@ -1271,6 +1272,110 @@ } } }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@discordjs/voice": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.1.tgz", @@ -1325,6 +1430,41 @@ } } }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", @@ -3631,6 +3771,39 @@ "node": ">= 10" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", @@ -4902,6 +5075,16 @@ "@types/node": "*" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@whiskeysockets/baileys": { "version": "7.0.0-rc.9", "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz", @@ -6624,6 +6807,42 @@ "scripts/actions/documentation" ] }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8706,6 +8925,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -8798,7 +9023,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.startcase": { @@ -8959,6 +9183,12 @@ "node": "20 || >=22" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -11556,6 +11786,12 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index c9f1156b2..218c2bc16 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { + "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, "files": [ diff --git a/scripts/bridge-core.js b/scripts/bridge-core.js new file mode 100644 index 000000000..860de9b90 --- /dev/null +++ b/scripts/bridge-core.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared bridge infrastructure for host-side messaging integrations. + * + * Handles the sandbox connection via OpenShell SSH — each messaging bridge + * (Telegram, Discord, Slack) imports this module for the relay logic and + * implements its own platform-specific API client. + * + * Env: + * NVIDIA_API_KEY — for inference (required) + * SANDBOX_NAME — sandbox name (default: nemoclaw) + */ + +const fs = require("fs"); +const { execFileSync, spawn } = require("child_process"); +const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); +const { shellQuote, validateName } = require("../bin/lib/runner"); + +const OPENSHELL = resolveOpenshell(); +if (!OPENSHELL) { + console.error("openshell not found on PATH or in common locations"); + process.exit(1); +} + +const API_KEY = process.env.NVIDIA_API_KEY; +if (!API_KEY) { + console.error("NVIDIA_API_KEY required"); + process.exit(1); +} + +const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; +try { + validateName(SANDBOX, "SANDBOX_NAME"); +} catch (e) { + console.error(e.message); + process.exit(1); +} + +/** + * Run the OpenClaw agent inside the sandbox via OpenShell SSH. + * + * @param {string} message - The user message to send to the agent + * @param {string} sessionId - Session identifier (prefixed per-platform by the caller) + * @returns {Promise} The agent's response text + */ +function runAgentInSandbox(message, sessionId) { + return new Promise((resolve) => { + const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { + encoding: "utf-8", + }); + + const confDir = fs.mkdtempSync("/tmp/nemoclaw-bridge-ssh-"); + const confPath = `${confDir}/config`; + fs.writeFileSync(confPath, sshConfig, { mode: 0o600 }); + + 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(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 { + fs.unlinkSync(confPath); + fs.rmdirSync(confDir); + } catch {} + + 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}`); + }); + }); +} + +module.exports = { + runAgentInSandbox, + SANDBOX, + API_KEY, + OPENSHELL, + shellQuote, + validateName, +}; diff --git a/scripts/discord-bridge.js b/scripts/discord-bridge.js new file mode 100644 index 000000000..cfa46af31 --- /dev/null +++ b/scripts/discord-bridge.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Discord → NemoClaw bridge. + * + * Messages from Discord 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 Discord. + * + * Env: + * DISCORD_BOT_TOKEN — from Discord Developer Portal + * NVIDIA_API_KEY — for inference + * SANDBOX_NAME — sandbox name (default: nemoclaw) + * ALLOWED_CHANNEL_IDS — comma-separated Discord channel IDs to accept (optional, accepts all if unset) + */ + +const { Client, GatewayIntentBits } = require("discord.js"); +const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); + +const TOKEN = process.env.DISCORD_BOT_TOKEN; +if (!TOKEN) { console.error("DISCORD_BOT_TOKEN required"); process.exit(1); } + +const ALLOWED_CHANNELS = process.env.ALLOWED_CHANNEL_IDS + ? process.env.ALLOWED_CHANNEL_IDS.split(",").map((s) => s.trim()) + : null; + +// ── Discord client setup ────────────────────────────────────────── + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], +}); + +// ── Message handling ────────────────────────────────────────────── + +client.on("messageCreate", async (message) => { + // Ignore bot messages (including our own) + if (message.author.bot) return; + + // Access control + const channelId = message.channel.id; + if (ALLOWED_CHANNELS && !ALLOWED_CHANNELS.includes(channelId)) return; + + const userName = message.author.username; + const text = message.content; + if (!text) return; + + console.log(`[${channelId}] ${userName}: ${text}`); + + // Send typing indicator + await message.channel.sendTyping().catch(() => {}); + const typingInterval = setInterval( + () => message.channel.sendTyping().catch(() => {}), + 4000, + ); + + try { + const response = await runAgentInSandbox(text, `dc-${channelId}`); + clearInterval(typingInterval); + console.log(`[${channelId}] agent: ${response.slice(0, 100)}...`); + + // Discord max message length is 2000 + const chunks = []; + for (let i = 0; i < response.length; i += 1900) { + chunks.push(response.slice(i, i + 1900)); + } + for (const chunk of chunks) { + await message.reply(chunk); + } + } catch (err) { + clearInterval(typingInterval); + await message.reply(`Error: ${err.message}`).catch(() => {}); + } +}); + +// ── Main ────────────────────────────────────────────────────────── + +client.once("ready", () => { + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Discord Bridge │"); + console.log(" │ │"); + console.log(` │ Bot: ${(client.user.tag + " ").slice(0, 41)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); + console.log(" │ │"); + console.log(" │ Messages are forwarded to the OpenClaw agent │"); + console.log(" │ inside the sandbox. Run 'openshell term' in │"); + console.log(" │ another terminal to monitor + approve egress. │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +}); + +client.login(TOKEN); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index cbce0f183..427afe6f5 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -144,7 +144,13 @@ do_start() { node "$REPO_DIR/scripts/telegram-bridge.js" fi - # 3. cloudflared tunnel + # Discord bridge (only if token provided) + if [ -n "${DISCORD_BOT_TOKEN:-}" ]; then + SANDBOX_NAME="$SANDBOX_NAME" start_service discord-bridge \ + node "$REPO_DIR/scripts/discord-bridge.js" + fi + + # cloudflared tunnel if command -v cloudflared > /dev/null 2>&1; then start_service cloudflared \ cloudflared tunnel --url "http://localhost:$DASHBOARD_PORT" diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index e885b09f2..712a16c51 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -17,28 +17,15 @@ */ const https = require("https"); -const { execFileSync, spawn } = require("child_process"); -const crypto = require("crypto"); -const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); -const { shellQuote, validateName } = require("../bin/lib/runner"); - -const OPENSHELL = resolveOpenshell(); -if (!OPENSHELL) { - console.error("openshell not found on PATH or in common locations"); - process.exit(1); -} +const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); 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); } +if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } + const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS ? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim()) : null; -if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } -if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } - let offset = 0; const activeSessions = new Map(); // chatId → message history @@ -91,70 +78,6 @@ 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 {} - - // 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() { @@ -206,7 +129,7 @@ async function poll() { const typingInterval = setInterval(() => sendTyping(chatId), 4000); try { - const response = await runAgentInSandbox(msg.text, chatId); + const response = await runAgentInSandbox(msg.text, `tg-${chatId}`); clearInterval(typingInterval); console.log(`[${chatId}] agent: ${response.slice(0, 100)}...`); await sendMessage(chatId, response, msg.message_id); diff --git a/test/runner.test.js b/test/runner.test.js index ffe064fc0..37efb5c01 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -200,11 +200,19 @@ describe("runner helpers", () => { } }); - it("telegram bridge validates SANDBOX_NAME on startup", () => { + it("bridge-core validates SANDBOX_NAME on startup", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); - assert.ok(src.includes("validateName(SANDBOX"), "telegram-bridge.js must validate SANDBOX_NAME"); - assert.ok(!src.includes("execSync"), "telegram-bridge.js should not use execSync"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge-core.js"), "utf-8"); + assert.ok(src.includes("validateName(SANDBOX"), "bridge-core.js must validate SANDBOX_NAME"); + assert.ok(!src.includes("execSync"), "bridge-core.js should not use execSync"); + }); + + it("bridges use shared bridge-core module", () => { + const fs = require("fs"); + const tg = fs.readFileSync(path.join(__dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + const dc = fs.readFileSync(path.join(__dirname, "..", "scripts", "discord-bridge.js"), "utf-8"); + assert.ok(tg.includes("require(\"./bridge-core\")"), "telegram-bridge.js must use bridge-core"); + assert.ok(dc.includes("require(\"./bridge-core\")"), "discord-bridge.js must use bridge-core"); }); }); }); From 04537f14b1228ac847e6cef33353cb1bcc23de17 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:06:18 -0700 Subject: [PATCH 03/15] fix: add host-side Slack bridge using bridge-core Completes the host-side bridge pattern for all three messaging integrations. Slack bridge uses @slack/bolt in Socket Mode (requires SLACK_BOT_TOKEN + SLACK_APP_TOKEN). Replies in threads, chunks long responses, and follows the same bridge-core relay pattern as Telegram and Discord. --- package-lock.json | 1 + package.json | 1 + scripts/slack-bridge.js | 87 +++++++++++++++++++++++++++++++++++++++ scripts/start-services.sh | 6 +++ test/runner.test.js | 2 + 5 files changed, 97 insertions(+) create mode 100644 scripts/slack-bridge.js diff --git a/package-lock.json b/package-lock.json index ceae7fbe1..38ccac9c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@slack/bolt": "^4.6.0", "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, diff --git a/package.json b/package.json index 218c2bc16..3b915f5fc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { + "@slack/bolt": "^4.6.0", "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, diff --git a/scripts/slack-bridge.js b/scripts/slack-bridge.js new file mode 100644 index 000000000..39c07125d --- /dev/null +++ b/scripts/slack-bridge.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Slack → NemoClaw bridge. + * + * Messages from Slack 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 Slack. + * + * Env: + * SLACK_BOT_TOKEN — Bot User OAuth Token (xoxb-...) + * SLACK_APP_TOKEN — App-Level Token (xapp-...) for Socket Mode + * NVIDIA_API_KEY — for inference + * SANDBOX_NAME — sandbox name (default: nemoclaw) + * ALLOWED_CHANNEL_IDS — comma-separated Slack channel IDs to accept (optional, accepts all if unset) + */ + +const { App } = require("@slack/bolt"); +const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); + +const BOT_TOKEN = process.env.SLACK_BOT_TOKEN; +const APP_TOKEN = process.env.SLACK_APP_TOKEN; +if (!BOT_TOKEN) { console.error("SLACK_BOT_TOKEN required"); process.exit(1); } +if (!APP_TOKEN) { console.error("SLACK_APP_TOKEN required (xapp-... for Socket Mode)"); process.exit(1); } + +const ALLOWED_CHANNELS = process.env.ALLOWED_CHANNEL_IDS + ? process.env.ALLOWED_CHANNEL_IDS.split(",").map((s) => s.trim()) + : null; + +// ── Slack app setup (Socket Mode) ───────────────────────────────── + +const app = new App({ + token: BOT_TOKEN, + appToken: APP_TOKEN, + socketMode: true, +}); + +// ── Message handling ────────────────────────────────────────────── + +app.message(async ({ message, say }) => { + // Ignore bot messages, edits, and thread broadcasts + if (message.subtype) return; + if (!message.text) return; + + const channelId = message.channel; + if (ALLOWED_CHANNELS && !ALLOWED_CHANNELS.includes(channelId)) return; + + const userName = message.user || "someone"; + console.log(`[${channelId}] ${userName}: ${message.text}`); + + try { + const response = await runAgentInSandbox(message.text, `sl-${channelId}`); + console.log(`[${channelId}] agent: ${response.slice(0, 100)}...`); + + // Slack max message length is 40000 but keep chunks readable + const chunks = []; + for (let i = 0; i < response.length; i += 3000) { + chunks.push(response.slice(i, i + 3000)); + } + for (const chunk of chunks) { + await say({ text: chunk, thread_ts: message.ts }); + } + } catch (err) { + await say({ text: `Error: ${err.message}`, thread_ts: message.ts }).catch(() => {}); + } +}); + +// ── Main ────────────────────────────────────────────────────────── + +(async () => { + await app.start(); + + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Slack Bridge │"); + console.log(" │ │"); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); + console.log(" │ │"); + console.log(" │ Messages are forwarded to the OpenClaw agent │"); + console.log(" │ inside the sandbox. Run 'openshell term' in │"); + console.log(" │ another terminal to monitor + approve egress. │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +})(); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 427afe6f5..a55d14da7 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -150,6 +150,12 @@ do_start() { node "$REPO_DIR/scripts/discord-bridge.js" fi + # Slack bridge (only if both tokens provided) + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_APP_TOKEN:-}" ]; then + SANDBOX_NAME="$SANDBOX_NAME" start_service slack-bridge \ + node "$REPO_DIR/scripts/slack-bridge.js" + fi + # cloudflared tunnel if command -v cloudflared > /dev/null 2>&1; then start_service cloudflared \ diff --git a/test/runner.test.js b/test/runner.test.js index 37efb5c01..fbe8870a0 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -211,8 +211,10 @@ describe("runner helpers", () => { const fs = require("fs"); const tg = fs.readFileSync(path.join(__dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); const dc = fs.readFileSync(path.join(__dirname, "..", "scripts", "discord-bridge.js"), "utf-8"); + const sl = fs.readFileSync(path.join(__dirname, "..", "scripts", "slack-bridge.js"), "utf-8"); assert.ok(tg.includes("require(\"./bridge-core\")"), "telegram-bridge.js must use bridge-core"); assert.ok(dc.includes("require(\"./bridge-core\")"), "discord-bridge.js must use bridge-core"); + assert.ok(sl.includes("require(\"./bridge-core\")"), "slack-bridge.js must use bridge-core"); }); }); }); From ea3fb46d35a7010828823ef8a3e2e63142ac9356 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:17:17 -0700 Subject: [PATCH 04/15] refactor: yaml-driven bridge architecture with adapter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three standalone bridge scripts with a generic bridge runner that reads YAML configs from nemoclaw-blueprint/bridges/ and loads thin platform adapters. Structure: - nemoclaw-blueprint/bridges/messaging/*.yaml — declarative config (token env, session prefix, chunk size, adapter name) - scripts/adapters/messaging/*.js — thin SDK wrappers that implement connect + send/receive for each platform - scripts/bridge.js — generic runner that reads YAML, loads adapter, and drives the shared message flow (typing → agent → chunk → reply) Adding a new messaging platform is now a YAML file + small adapter. The bridge type system (messaging/) is extensible to future bridge types (e.g., MCP bridges). --- .../bridges/messaging/discord.yaml | 16 ++ .../bridges/messaging/slack.yaml | 18 ++ .../bridges/messaging/telegram.yaml | 16 ++ scripts/adapters/messaging/discord.js | 57 +++++ scripts/adapters/messaging/slack.js | 58 +++++ scripts/adapters/messaging/telegram.js | 99 ++++++++ scripts/bridge.js | 217 ++++++++++++++++++ scripts/discord-bridge.js | 100 -------- scripts/slack-bridge.js | 87 ------- scripts/start-services.sh | 12 +- scripts/telegram-bridge.js | 176 -------------- test/runner.test.js | 34 ++- 12 files changed, 512 insertions(+), 378 deletions(-) create mode 100644 nemoclaw-blueprint/bridges/messaging/discord.yaml create mode 100644 nemoclaw-blueprint/bridges/messaging/slack.yaml create mode 100644 nemoclaw-blueprint/bridges/messaging/telegram.yaml create mode 100644 scripts/adapters/messaging/discord.js create mode 100644 scripts/adapters/messaging/slack.js create mode 100644 scripts/adapters/messaging/telegram.js create mode 100644 scripts/bridge.js delete mode 100644 scripts/discord-bridge.js delete mode 100644 scripts/slack-bridge.js delete mode 100755 scripts/telegram-bridge.js diff --git a/nemoclaw-blueprint/bridges/messaging/discord.yaml b/nemoclaw-blueprint/bridges/messaging/discord.yaml new file mode 100644 index 000000000..32a0d5cd6 --- /dev/null +++ b/nemoclaw-blueprint/bridges/messaging/discord.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +bridge: + name: discord + type: messaging + adapter: discord + description: "Discord bot bridge" + + credentials: + token_env: DISCORD_BOT_TOKEN + allowed_env: ALLOWED_CHANNEL_IDS + + messaging: + session_prefix: dc + max_chunk_size: 1900 diff --git a/nemoclaw-blueprint/bridges/messaging/slack.yaml b/nemoclaw-blueprint/bridges/messaging/slack.yaml new file mode 100644 index 000000000..cd6f16cc1 --- /dev/null +++ b/nemoclaw-blueprint/bridges/messaging/slack.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +bridge: + name: slack + type: messaging + adapter: slack + description: "Slack bot bridge (Socket Mode)" + + credentials: + token_env: SLACK_BOT_TOKEN + extra_env: + - SLACK_APP_TOKEN + allowed_env: ALLOWED_CHANNEL_IDS + + messaging: + session_prefix: sl + max_chunk_size: 3000 diff --git a/nemoclaw-blueprint/bridges/messaging/telegram.yaml b/nemoclaw-blueprint/bridges/messaging/telegram.yaml new file mode 100644 index 000000000..572a0b6e8 --- /dev/null +++ b/nemoclaw-blueprint/bridges/messaging/telegram.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +bridge: + name: telegram + type: messaging + adapter: telegram + description: "Telegram Bot API bridge" + + credentials: + token_env: TELEGRAM_BOT_TOKEN + allowed_env: ALLOWED_CHAT_IDS + + messaging: + session_prefix: tg + max_chunk_size: 4000 diff --git a/scripts/adapters/messaging/discord.js b/scripts/adapters/messaging/discord.js new file mode 100644 index 000000000..f44bd0b91 --- /dev/null +++ b/scripts/adapters/messaging/discord.js @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Discord messaging adapter. + * + * Uses discord.js for WebSocket-based gateway connection. + */ + +const { Client, GatewayIntentBits } = require("discord.js"); + +module.exports = function createAdapter(config) { + const TOKEN = process.env[config.credentials.token_env]; + const ALLOWED = process.env[config.credentials.allowed_env] + ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + : null; + + return { + name: "discord", + + start(onMessage) { + return new Promise((resolve, reject) => { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); + + client.on("messageCreate", async (message) => { + if (message.author.bot) return; + + const channelId = message.channel.id; + if (ALLOWED && !ALLOWED.includes(channelId)) return; + if (!message.content) return; + + await onMessage({ + channelId, + userName: message.author.username, + text: message.content, + async sendTyping() { + await message.channel.sendTyping().catch(() => {}); + }, + async reply(text) { + await message.reply(text); + }, + }); + }); + + client.once("ready", () => resolve(client.user.tag)); + client.login(TOKEN).catch(reject); + }); + }, + }; +}; diff --git a/scripts/adapters/messaging/slack.js b/scripts/adapters/messaging/slack.js new file mode 100644 index 000000000..722942e03 --- /dev/null +++ b/scripts/adapters/messaging/slack.js @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Slack messaging adapter. + * + * Uses @slack/bolt in Socket Mode (requires SLACK_APP_TOKEN). + */ + +const { App } = require("@slack/bolt"); + +module.exports = function createAdapter(config) { + const BOT_TOKEN = process.env[config.credentials.token_env]; + const APP_TOKEN = process.env.SLACK_APP_TOKEN; + const ALLOWED = process.env[config.credentials.allowed_env] + ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + : null; + + if (!APP_TOKEN) { + console.error("SLACK_APP_TOKEN required (xapp-... for Socket Mode)"); + process.exit(1); + } + + return { + name: "slack", + + async start(onMessage) { + const app = new App({ + token: BOT_TOKEN, + appToken: APP_TOKEN, + socketMode: true, + }); + + app.message(async ({ message, say }) => { + if (message.subtype) return; + if (!message.text) return; + + const channelId = message.channel; + if (ALLOWED && !ALLOWED.includes(channelId)) return; + + await onMessage({ + channelId, + userName: message.user || "someone", + text: message.text, + async sendTyping() { + // Slack doesn't have a direct typing indicator API for bots + }, + async reply(text) { + await say({ text, thread_ts: message.ts }); + }, + }); + }); + + await app.start(); + return "Slack Bot (Socket Mode)"; + }, + }; +}; diff --git a/scripts/adapters/messaging/telegram.js b/scripts/adapters/messaging/telegram.js new file mode 100644 index 000000000..d979d6bdd --- /dev/null +++ b/scripts/adapters/messaging/telegram.js @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Telegram messaging adapter. + * + * Uses the Telegram Bot API via long polling (no external dependencies). + */ + +const https = require("https"); + +module.exports = function createAdapter(config) { + const TOKEN = process.env[config.credentials.token_env]; + const ALLOWED = process.env[config.credentials.allowed_env] + ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + : null; + + let offset = 0; + + 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(); + }); + } + + return { + name: "telegram", + + async start(onMessage) { + const me = await tgApi("getMe", {}); + if (!me.ok) { + throw new Error(`Failed to connect to Telegram: ${JSON.stringify(me)}`); + } + + const botName = `@${me.result.username}`; + + 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 channelId = String(msg.chat.id); + if (ALLOWED && !ALLOWED.includes(channelId)) continue; + + const userName = msg.from?.first_name || "someone"; + + await onMessage({ + channelId, + userName, + text: msg.text, + async sendTyping() { + await tgApi("sendChatAction", { chat_id: channelId, action: "typing" }).catch(() => {}); + }, + async reply(text) { + await tgApi("sendMessage", { + chat_id: channelId, + text, + reply_to_message_id: msg.message_id, + parse_mode: "Markdown", + }).catch(() => + tgApi("sendMessage", { chat_id: channelId, text, reply_to_message_id: msg.message_id }), + ); + }, + }); + } + } + } catch (err) { + console.error("Poll error:", err.message); + } + setTimeout(poll, 100); + } + + poll(); + return botName; + }, + }; +}; diff --git a/scripts/bridge.js b/scripts/bridge.js new file mode 100644 index 000000000..51bd91428 --- /dev/null +++ b/scripts/bridge.js @@ -0,0 +1,217 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generic bridge runner. + * + * Reads bridge definitions from nemoclaw-blueprint/bridges//*.yaml, + * loads the corresponding adapter, and runs the message flow. Credentials + * stay on the host — messages relay to the sandbox via OpenShell SSH. + * + * Usage: + * node scripts/bridge.js Run a specific bridge by name + * node scripts/bridge.js --list List available bridges + * + * Env: + * NVIDIA_API_KEY — required for inference + * SANDBOX_NAME — sandbox name (default: nemoclaw) + * Platform-specific tokens (see bridge YAML for token_env) + */ + +const fs = require("fs"); +const path = require("path"); +const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); + +const BRIDGES_DIR = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges"); + +// ── YAML parser (minimal, no dependency) ────────────────────────── + +function parseYaml(text) { + // Simple YAML parser for flat/nested key-value configs. + // Handles: scalars, nested objects, arrays of scalars. No anchors/aliases. + const result = {}; + const stack = [{ obj: result, indent: -1 }]; + + for (const raw of text.split("\n")) { + if (raw.trim() === "" || raw.trim().startsWith("#")) continue; + + const indent = raw.search(/\S/); + const line = raw.trim(); + + // Pop stack to matching indent level + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + const parent = stack[stack.length - 1].obj; + + // Array item + if (line.startsWith("- ")) { + const val = line.slice(2).trim(); + const lastKey = Object.keys(parent).pop(); + if (lastKey && !Array.isArray(parent[lastKey])) { + parent[lastKey] = []; + } + if (lastKey) parent[lastKey].push(unquote(val)); + continue; + } + + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + const valPart = line.slice(colonIdx + 1).trim(); + + if (valPart === "" || valPart === "|") { + // Nested object + parent[key] = {}; + stack.push({ obj: parent[key], indent }); + } else { + parent[key] = unquote(valPart); + } + } + + return result; +} + +function unquote(s) { + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { + return s.slice(1, -1); + } + if (s === "true") return true; + if (s === "false") return false; + const n = Number(s); + if (!isNaN(n) && s !== "") return n; + return s; +} + +// ── Load bridge configs ─────────────────────────────────────────── + +function loadBridgeConfigs() { + const configs = []; + if (!fs.existsSync(BRIDGES_DIR)) return configs; + + for (const typeDir of fs.readdirSync(BRIDGES_DIR, { withFileTypes: true })) { + if (!typeDir.isDirectory()) continue; + const typePath = path.join(BRIDGES_DIR, typeDir.name); + + for (const file of fs.readdirSync(typePath)) { + if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue; + const content = fs.readFileSync(path.join(typePath, file), "utf-8"); + const parsed = parseYaml(content); + if (parsed.bridge) configs.push(parsed.bridge); + } + } + + return configs; +} + +function findAdapter(config) { + const adapterPath = path.join(__dirname, "adapters", config.type, `${config.adapter}.js`); + if (!fs.existsSync(adapterPath)) { + console.error(`Adapter not found: ${adapterPath}`); + return null; + } + return require(adapterPath); +} + +// ── Message flow engine ─────────────────────────────────────────── + +async function runBridge(config) { + const tokenEnv = config.credentials.token_env; + const token = process.env[tokenEnv]; + if (!token) { + console.error(`${tokenEnv} required for ${config.name} bridge`); + process.exit(1); + } + + // Check extra required env vars (e.g., SLACK_APP_TOKEN) + const extraEnvs = config.credentials.extra_env; + if (Array.isArray(extraEnvs)) { + for (const env of extraEnvs) { + if (!process.env[env]) { + console.error(`${env} required for ${config.name} bridge`); + process.exit(1); + } + } + } + + const createAdapter = findAdapter(config); + if (!createAdapter) process.exit(1); + + const adapter = createAdapter(config); + const prefix = config.messaging.session_prefix; + const maxChunk = config.messaging.max_chunk_size; + + async function onMessage(msg) { + console.log(`[${config.name}] [${msg.channelId}] ${msg.userName}: ${msg.text}`); + + // Typing indicator + await msg.sendTyping(); + const typingInterval = setInterval(() => msg.sendTyping(), 4000); + + try { + const response = await runAgentInSandbox(msg.text, `${prefix}-${msg.channelId}`); + clearInterval(typingInterval); + console.log(`[${config.name}] [${msg.channelId}] agent: ${response.slice(0, 100)}...`); + + // Chunk response per platform limit + const chunks = []; + for (let i = 0; i < response.length; i += maxChunk) { + chunks.push(response.slice(i, i + maxChunk)); + } + for (const chunk of chunks) { + await msg.reply(chunk); + } + } catch (err) { + clearInterval(typingInterval); + await msg.reply(`Error: ${err.message}`).catch(() => {}); + } + } + + const botName = await adapter.start(onMessage); + + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(` │ NemoClaw ${(config.name.charAt(0).toUpperCase() + config.name.slice(1) + " Bridge ").slice(0, 41)}│`); + console.log(" │ │"); + console.log(` │ Bot: ${(String(botName) + " ").slice(0, 41)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + console.log(" │ │"); + console.log(" │ Messages are forwarded to the OpenClaw agent │"); + console.log(" │ inside the sandbox. Run 'openshell term' in │"); + console.log(" │ another terminal to monitor + approve egress. │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +} + +// ── CLI ─────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +if (args[0] === "--list") { + const configs = loadBridgeConfigs(); + console.log("\nAvailable bridges:\n"); + for (const c of configs) { + const token = process.env[c.credentials.token_env] ? "✓" : "✗"; + console.log(` ${token} ${c.name.padEnd(12)} ${c.description} (${c.credentials.token_env})`); + } + console.log(""); + process.exit(0); +} + +if (!args[0]) { + console.error("Usage: node scripts/bridge.js "); + console.error(" node scripts/bridge.js --list"); + process.exit(1); +} + +const configs = loadBridgeConfigs(); +const config = configs.find((c) => c.name === args[0]); +if (!config) { + console.error(`Unknown bridge: ${args[0]}`); + console.error(`Available: ${configs.map((c) => c.name).join(", ")}`); + process.exit(1); +} + +runBridge(config); diff --git a/scripts/discord-bridge.js b/scripts/discord-bridge.js deleted file mode 100644 index cfa46af31..000000000 --- a/scripts/discord-bridge.js +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Discord → NemoClaw bridge. - * - * Messages from Discord 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 Discord. - * - * Env: - * DISCORD_BOT_TOKEN — from Discord Developer Portal - * NVIDIA_API_KEY — for inference - * SANDBOX_NAME — sandbox name (default: nemoclaw) - * ALLOWED_CHANNEL_IDS — comma-separated Discord channel IDs to accept (optional, accepts all if unset) - */ - -const { Client, GatewayIntentBits } = require("discord.js"); -const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); - -const TOKEN = process.env.DISCORD_BOT_TOKEN; -if (!TOKEN) { console.error("DISCORD_BOT_TOKEN required"); process.exit(1); } - -const ALLOWED_CHANNELS = process.env.ALLOWED_CHANNEL_IDS - ? process.env.ALLOWED_CHANNEL_IDS.split(",").map((s) => s.trim()) - : null; - -// ── Discord client setup ────────────────────────────────────────── - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - ], -}); - -// ── Message handling ────────────────────────────────────────────── - -client.on("messageCreate", async (message) => { - // Ignore bot messages (including our own) - if (message.author.bot) return; - - // Access control - const channelId = message.channel.id; - if (ALLOWED_CHANNELS && !ALLOWED_CHANNELS.includes(channelId)) return; - - const userName = message.author.username; - const text = message.content; - if (!text) return; - - console.log(`[${channelId}] ${userName}: ${text}`); - - // Send typing indicator - await message.channel.sendTyping().catch(() => {}); - const typingInterval = setInterval( - () => message.channel.sendTyping().catch(() => {}), - 4000, - ); - - try { - const response = await runAgentInSandbox(text, `dc-${channelId}`); - clearInterval(typingInterval); - console.log(`[${channelId}] agent: ${response.slice(0, 100)}...`); - - // Discord max message length is 2000 - const chunks = []; - for (let i = 0; i < response.length; i += 1900) { - chunks.push(response.slice(i, i + 1900)); - } - for (const chunk of chunks) { - await message.reply(chunk); - } - } catch (err) { - clearInterval(typingInterval); - await message.reply(`Error: ${err.message}`).catch(() => {}); - } -}); - -// ── Main ────────────────────────────────────────────────────────── - -client.once("ready", () => { - console.log(""); - console.log(" ┌─────────────────────────────────────────────────────┐"); - console.log(" │ NemoClaw Discord Bridge │"); - console.log(" │ │"); - console.log(` │ Bot: ${(client.user.tag + " ").slice(0, 41)}│`); - console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); - console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); - console.log(" │ │"); - console.log(" │ Messages are forwarded to the OpenClaw agent │"); - console.log(" │ inside the sandbox. Run 'openshell term' in │"); - console.log(" │ another terminal to monitor + approve egress. │"); - console.log(" └─────────────────────────────────────────────────────┘"); - console.log(""); -}); - -client.login(TOKEN); diff --git a/scripts/slack-bridge.js b/scripts/slack-bridge.js deleted file mode 100644 index 39c07125d..000000000 --- a/scripts/slack-bridge.js +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Slack → NemoClaw bridge. - * - * Messages from Slack 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 Slack. - * - * Env: - * SLACK_BOT_TOKEN — Bot User OAuth Token (xoxb-...) - * SLACK_APP_TOKEN — App-Level Token (xapp-...) for Socket Mode - * NVIDIA_API_KEY — for inference - * SANDBOX_NAME — sandbox name (default: nemoclaw) - * ALLOWED_CHANNEL_IDS — comma-separated Slack channel IDs to accept (optional, accepts all if unset) - */ - -const { App } = require("@slack/bolt"); -const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); - -const BOT_TOKEN = process.env.SLACK_BOT_TOKEN; -const APP_TOKEN = process.env.SLACK_APP_TOKEN; -if (!BOT_TOKEN) { console.error("SLACK_BOT_TOKEN required"); process.exit(1); } -if (!APP_TOKEN) { console.error("SLACK_APP_TOKEN required (xapp-... for Socket Mode)"); process.exit(1); } - -const ALLOWED_CHANNELS = process.env.ALLOWED_CHANNEL_IDS - ? process.env.ALLOWED_CHANNEL_IDS.split(",").map((s) => s.trim()) - : null; - -// ── Slack app setup (Socket Mode) ───────────────────────────────── - -const app = new App({ - token: BOT_TOKEN, - appToken: APP_TOKEN, - socketMode: true, -}); - -// ── Message handling ────────────────────────────────────────────── - -app.message(async ({ message, say }) => { - // Ignore bot messages, edits, and thread broadcasts - if (message.subtype) return; - if (!message.text) return; - - const channelId = message.channel; - if (ALLOWED_CHANNELS && !ALLOWED_CHANNELS.includes(channelId)) return; - - const userName = message.user || "someone"; - console.log(`[${channelId}] ${userName}: ${message.text}`); - - try { - const response = await runAgentInSandbox(message.text, `sl-${channelId}`); - console.log(`[${channelId}] agent: ${response.slice(0, 100)}...`); - - // Slack max message length is 40000 but keep chunks readable - const chunks = []; - for (let i = 0; i < response.length; i += 3000) { - chunks.push(response.slice(i, i + 3000)); - } - for (const chunk of chunks) { - await say({ text: chunk, thread_ts: message.ts }); - } - } catch (err) { - await say({ text: `Error: ${err.message}`, thread_ts: message.ts }).catch(() => {}); - } -}); - -// ── Main ────────────────────────────────────────────────────────── - -(async () => { - await app.start(); - - console.log(""); - console.log(" ┌─────────────────────────────────────────────────────┐"); - console.log(" │ NemoClaw Slack Bridge │"); - console.log(" │ │"); - console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); - console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); - console.log(" │ │"); - console.log(" │ Messages are forwarded to the OpenClaw agent │"); - console.log(" │ inside the sandbox. Run 'openshell term' in │"); - console.log(" │ another terminal to monitor + approve egress. │"); - console.log(" └─────────────────────────────────────────────────────┘"); - console.log(""); -})(); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index a55d14da7..2589e20e4 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -138,22 +138,18 @@ do_start() { mkdir -p "$PIDDIR" - # Telegram bridge (only if token provided) + # Messaging bridges — start each if its token is set if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ - node "$REPO_DIR/scripts/telegram-bridge.js" + node "$REPO_DIR/scripts/bridge.js" telegram fi - - # Discord bridge (only if token provided) if [ -n "${DISCORD_BOT_TOKEN:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service discord-bridge \ - node "$REPO_DIR/scripts/discord-bridge.js" + node "$REPO_DIR/scripts/bridge.js" discord fi - - # Slack bridge (only if both tokens provided) if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_APP_TOKEN:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service slack-bridge \ - node "$REPO_DIR/scripts/slack-bridge.js" + node "$REPO_DIR/scripts/bridge.js" slack fi # cloudflared tunnel diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js deleted file mode 100755 index 712a16c51..000000000 --- a/scripts/telegram-bridge.js +++ /dev/null @@ -1,176 +0,0 @@ -#!/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 (optional, accepts all if unset) - */ - -const https = require("https"); -const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); - -const TOKEN = process.env.TELEGRAM_BOT_TOKEN; -if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } - -const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS - ? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim()) - : null; - -let offset = 0; -const activeSessions = new Map(); // chatId → message history - -// ── 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(() => {}); -} - -// ── 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 (ALLOWED_CHATS && !ALLOWED_CHATS.includes(chatId)) { - console.log(`[ignored] chat ${chatId} not in allowed list`); - continue; - } - - const userName = msg.from?.first_name || "someone"; - console.log(`[${chatId}] ${userName}: ${msg.text}`); - - // 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; - } - - // 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, `tg-${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); - } - } - } - } catch (err) { - console.error("Poll error:", err.message); - } - - // Continue polling - setTimeout(poll, 100); -} - -// ── 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(" │ │"); - 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/test/runner.test.js b/test/runner.test.js index fbe8870a0..310226ba9 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -207,14 +207,34 @@ describe("runner helpers", () => { assert.ok(!src.includes("execSync"), "bridge-core.js should not use execSync"); }); - it("bridges use shared bridge-core module", () => { + it("bridge runner uses bridge-core for sandbox relay", () => { const fs = require("fs"); - const tg = fs.readFileSync(path.join(__dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); - const dc = fs.readFileSync(path.join(__dirname, "..", "scripts", "discord-bridge.js"), "utf-8"); - const sl = fs.readFileSync(path.join(__dirname, "..", "scripts", "slack-bridge.js"), "utf-8"); - assert.ok(tg.includes("require(\"./bridge-core\")"), "telegram-bridge.js must use bridge-core"); - assert.ok(dc.includes("require(\"./bridge-core\")"), "discord-bridge.js must use bridge-core"); - assert.ok(sl.includes("require(\"./bridge-core\")"), "slack-bridge.js must use bridge-core"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge.js"), "utf-8"); + assert.ok(src.includes("require(\"./bridge-core\")"), "bridge.js must use bridge-core"); + assert.ok(src.includes("runAgentInSandbox"), "bridge.js must use runAgentInSandbox"); + }); + + it("each messaging adapter exists and exports a function", () => { + const fs = require("fs"); + const adaptersDir = path.join(__dirname, "..", "scripts", "adapters", "messaging"); + for (const name of ["telegram", "discord", "slack"]) { + const adapterPath = path.join(adaptersDir, `${name}.js`); + assert.ok(fs.existsSync(adapterPath), `adapter ${name}.js must exist`); + const src = fs.readFileSync(adapterPath, "utf-8"); + assert.ok(src.includes("module.exports"), `${name}.js must export a function`); + } + }); + + it("each messaging bridge has a YAML config", () => { + const fs = require("fs"); + const bridgesDir = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges", "messaging"); + for (const name of ["telegram", "discord", "slack"]) { + const yamlPath = path.join(bridgesDir, `${name}.yaml`); + assert.ok(fs.existsSync(yamlPath), `bridge config ${name}.yaml must exist`); + const src = fs.readFileSync(yamlPath, "utf-8"); + assert.ok(src.includes("token_env:"), `${name}.yaml must specify token_env`); + assert.ok(src.includes("session_prefix:"), `${name}.yaml must specify session_prefix`); + } }); }); }); From bff2a60573b9f04299eebe177d5142626d704cef Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:21:17 -0700 Subject: [PATCH 05/15] fix: address CodeRabbit findings on bridge architecture - Replace no-op spawn() timeout with manual setTimeout + proc.kill() - Add Partials.Channel to Discord adapter for DM support - Add discord-bridge and slack-bridge to show_status() and do_stop() - Normalize error messages in catch blocks for non-Error throws --- scripts/adapters/messaging/discord.js | 3 ++- scripts/bridge-core.js | 10 ++++++++-- scripts/bridge.js | 3 ++- scripts/start-services.sh | 4 +++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/adapters/messaging/discord.js b/scripts/adapters/messaging/discord.js index f44bd0b91..173f7852b 100644 --- a/scripts/adapters/messaging/discord.js +++ b/scripts/adapters/messaging/discord.js @@ -7,7 +7,7 @@ * Uses discord.js for WebSocket-based gateway connection. */ -const { Client, GatewayIntentBits } = require("discord.js"); +const { Client, GatewayIntentBits, Partials } = require("discord.js"); module.exports = function createAdapter(config) { const TOKEN = process.env[config.credentials.token_env]; @@ -27,6 +27,7 @@ module.exports = function createAdapter(config) { GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], + partials: [Partials.Channel], }); client.on("messageCreate", async (message) => { diff --git a/scripts/bridge-core.js b/scripts/bridge-core.js index 860de9b90..08f0b8732 100644 --- a/scripts/bridge-core.js +++ b/scripts/bridge-core.js @@ -63,10 +63,14 @@ function runAgentInSandbox(message, sessionId) { `-m ${shellQuote(message)} --session-id ${shellQuote(safeSessionId)}`; const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { - timeout: 120000, stdio: ["ignore", "pipe", "pipe"], }); + // spawn() does not support timeout — implement manually + const killTimer = setTimeout(() => { + proc.kill("SIGTERM"); + }, 120000); + let stdout = ""; let stderr = ""; @@ -74,6 +78,7 @@ function runAgentInSandbox(message, sessionId) { proc.stderr.on("data", (d) => (stderr += d.toString())); proc.on("close", (code) => { + clearTimeout(killTimer); try { fs.unlinkSync(confPath); fs.rmdirSync(confDir); @@ -106,7 +111,8 @@ function runAgentInSandbox(message, sessionId) { }); proc.on("error", (err) => { - resolve(`Error: ${err.message}`); + clearTimeout(killTimer); + resolve(`Error: ${err && err.message ? err.message : String(err)}`); }); }); } diff --git a/scripts/bridge.js b/scripts/bridge.js index 51bd91428..2c4873b1f 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -165,7 +165,8 @@ async function runBridge(config) { } } catch (err) { clearInterval(typingInterval); - await msg.reply(`Error: ${err.message}`).catch(() => {}); + const errorMsg = err && err.message ? err.message : String(err); + await msg.reply(`Error: ${errorMsg}`).catch(() => {}); } } diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 2589e20e4..b3f18d610 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -94,7 +94,7 @@ stop_service() { show_status() { mkdir -p "$PIDDIR" echo "" - for svc in telegram-bridge cloudflared; do + for svc in telegram-bridge discord-bridge slack-bridge cloudflared; do if is_running "$svc"; then echo -e " ${GREEN}●${NC} $svc (PID $(cat "$PIDDIR/$svc.pid"))" else @@ -115,6 +115,8 @@ show_status() { do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared + stop_service slack-bridge + stop_service discord-bridge stop_service telegram-bridge info "All services stopped." } From b875742611a683aea41f3b66d80aa1810a293396 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:31:16 -0700 Subject: [PATCH 06/15] fix: log metadata only in bridge message flow, never raw content Replace raw message text and agent response logging with metadata (channel ID, username, message length, response length) to avoid leaking PII or secrets into host logs. --- scripts/bridge.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bridge.js b/scripts/bridge.js index 2c4873b1f..1562cfc59 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -144,7 +144,7 @@ async function runBridge(config) { const maxChunk = config.messaging.max_chunk_size; async function onMessage(msg) { - console.log(`[${config.name}] [${msg.channelId}] ${msg.userName}: ${msg.text}`); + console.log(`[${config.name}] [${msg.channelId}] ${msg.userName}: inbound (len=${msg.text.length})`); // Typing indicator await msg.sendTyping(); @@ -153,7 +153,7 @@ async function runBridge(config) { try { const response = await runAgentInSandbox(msg.text, `${prefix}-${msg.channelId}`); clearInterval(typingInterval); - console.log(`[${config.name}] [${msg.channelId}] agent: ${response.slice(0, 100)}...`); + console.log(`[${config.name}] [${msg.channelId}] agent: response (len=${response.length})`); // Chunk response per platform limit const chunks = []; From 223b650f708937f2f1b0fadefa9ab2b3c02671ff Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:35:00 -0700 Subject: [PATCH 07/15] fix: replace hand-rolled yaml parser with js-yaml The minimal YAML parser silently dropped array items under nested keys (e.g., extra_env: [SLACK_APP_TOKEN] parsed as {}). Use js-yaml which is already in the dependency tree via eslint. --- scripts/bridge.js | 64 +++-------------------------------------------- 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/scripts/bridge.js b/scripts/bridge.js index 1562cfc59..889f1c46b 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -23,67 +23,9 @@ const fs = require("fs"); const path = require("path"); const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); -const BRIDGES_DIR = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges"); - -// ── YAML parser (minimal, no dependency) ────────────────────────── - -function parseYaml(text) { - // Simple YAML parser for flat/nested key-value configs. - // Handles: scalars, nested objects, arrays of scalars. No anchors/aliases. - const result = {}; - const stack = [{ obj: result, indent: -1 }]; - - for (const raw of text.split("\n")) { - if (raw.trim() === "" || raw.trim().startsWith("#")) continue; - - const indent = raw.search(/\S/); - const line = raw.trim(); - - // Pop stack to matching indent level - while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { - stack.pop(); - } - const parent = stack[stack.length - 1].obj; - - // Array item - if (line.startsWith("- ")) { - const val = line.slice(2).trim(); - const lastKey = Object.keys(parent).pop(); - if (lastKey && !Array.isArray(parent[lastKey])) { - parent[lastKey] = []; - } - if (lastKey) parent[lastKey].push(unquote(val)); - continue; - } - - const colonIdx = line.indexOf(":"); - if (colonIdx === -1) continue; +const yaml = require("js-yaml"); - const key = line.slice(0, colonIdx).trim(); - const valPart = line.slice(colonIdx + 1).trim(); - - if (valPart === "" || valPart === "|") { - // Nested object - parent[key] = {}; - stack.push({ obj: parent[key], indent }); - } else { - parent[key] = unquote(valPart); - } - } - - return result; -} - -function unquote(s) { - if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { - return s.slice(1, -1); - } - if (s === "true") return true; - if (s === "false") return false; - const n = Number(s); - if (!isNaN(n) && s !== "") return n; - return s; -} +const BRIDGES_DIR = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges"); // ── Load bridge configs ─────────────────────────────────────────── @@ -98,7 +40,7 @@ function loadBridgeConfigs() { for (const file of fs.readdirSync(typePath)) { if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue; const content = fs.readFileSync(path.join(typePath, file), "utf-8"); - const parsed = parseYaml(content); + const parsed = yaml.load(content); if (parsed.bridge) configs.push(parsed.bridge); } } From c019a7cfcc16fce0819c504bbf0aba431efb96a7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:39:05 -0700 Subject: [PATCH 08/15] refactor: move bridge configs into blueprint.yaml components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge definitions now live in components.bridges alongside sandbox, inference, and policy — following the existing blueprint schema with credential_env field naming. Removes the separate bridges/ directory and hand-rolled YAML parser in favor of js-yaml reading blueprint.yaml. --- nemoclaw-blueprint/blueprint.yaml | 26 ++++++++++++ .../bridges/messaging/discord.yaml | 16 -------- .../bridges/messaging/slack.yaml | 18 -------- .../bridges/messaging/telegram.yaml | 16 -------- scripts/adapters/messaging/discord.js | 6 +-- scripts/adapters/messaging/slack.js | 6 +-- scripts/adapters/messaging/telegram.js | 6 +-- scripts/bridge.js | 41 +++++++++---------- test/runner.test.js | 15 +++---- 9 files changed, 62 insertions(+), 88 deletions(-) delete mode 100644 nemoclaw-blueprint/bridges/messaging/discord.yaml delete mode 100644 nemoclaw-blueprint/bridges/messaging/slack.yaml delete mode 100644 nemoclaw-blueprint/bridges/messaging/telegram.yaml diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index f55f9f651..a6a20c106 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -54,6 +54,32 @@ components: credential_env: "OPENAI_API_KEY" credential_default: "dummy" + bridges: + telegram: + type: messaging + adapter: telegram + credential_env: "TELEGRAM_BOT_TOKEN" + session_prefix: tg + max_chunk_size: 4000 + + discord: + type: messaging + adapter: discord + credential_env: "DISCORD_BOT_TOKEN" + allowed_env: "ALLOWED_CHANNEL_IDS" + session_prefix: dc + max_chunk_size: 1900 + + slack: + type: messaging + adapter: slack + credential_env: "SLACK_BOT_TOKEN" + extra_credential_env: + - "SLACK_APP_TOKEN" + allowed_env: "ALLOWED_CHANNEL_IDS" + session_prefix: sl + max_chunk_size: 3000 + policy: base: "sandboxes/openclaw/policy.yaml" additions: diff --git a/nemoclaw-blueprint/bridges/messaging/discord.yaml b/nemoclaw-blueprint/bridges/messaging/discord.yaml deleted file mode 100644 index 32a0d5cd6..000000000 --- a/nemoclaw-blueprint/bridges/messaging/discord.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -bridge: - name: discord - type: messaging - adapter: discord - description: "Discord bot bridge" - - credentials: - token_env: DISCORD_BOT_TOKEN - allowed_env: ALLOWED_CHANNEL_IDS - - messaging: - session_prefix: dc - max_chunk_size: 1900 diff --git a/nemoclaw-blueprint/bridges/messaging/slack.yaml b/nemoclaw-blueprint/bridges/messaging/slack.yaml deleted file mode 100644 index cd6f16cc1..000000000 --- a/nemoclaw-blueprint/bridges/messaging/slack.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -bridge: - name: slack - type: messaging - adapter: slack - description: "Slack bot bridge (Socket Mode)" - - credentials: - token_env: SLACK_BOT_TOKEN - extra_env: - - SLACK_APP_TOKEN - allowed_env: ALLOWED_CHANNEL_IDS - - messaging: - session_prefix: sl - max_chunk_size: 3000 diff --git a/nemoclaw-blueprint/bridges/messaging/telegram.yaml b/nemoclaw-blueprint/bridges/messaging/telegram.yaml deleted file mode 100644 index 572a0b6e8..000000000 --- a/nemoclaw-blueprint/bridges/messaging/telegram.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -bridge: - name: telegram - type: messaging - adapter: telegram - description: "Telegram Bot API bridge" - - credentials: - token_env: TELEGRAM_BOT_TOKEN - allowed_env: ALLOWED_CHAT_IDS - - messaging: - session_prefix: tg - max_chunk_size: 4000 diff --git a/scripts/adapters/messaging/discord.js b/scripts/adapters/messaging/discord.js index 173f7852b..99c294abc 100644 --- a/scripts/adapters/messaging/discord.js +++ b/scripts/adapters/messaging/discord.js @@ -10,9 +10,9 @@ const { Client, GatewayIntentBits, Partials } = require("discord.js"); module.exports = function createAdapter(config) { - const TOKEN = process.env[config.credentials.token_env]; - const ALLOWED = process.env[config.credentials.allowed_env] - ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + const TOKEN = process.env[config.credential_env]; + const ALLOWED = process.env[config.allowed_env] + ? process.env[config.allowed_env].split(",").map((s) => s.trim()) : null; return { diff --git a/scripts/adapters/messaging/slack.js b/scripts/adapters/messaging/slack.js index 722942e03..a3acfece8 100644 --- a/scripts/adapters/messaging/slack.js +++ b/scripts/adapters/messaging/slack.js @@ -10,10 +10,10 @@ const { App } = require("@slack/bolt"); module.exports = function createAdapter(config) { - const BOT_TOKEN = process.env[config.credentials.token_env]; + const BOT_TOKEN = process.env[config.credential_env]; const APP_TOKEN = process.env.SLACK_APP_TOKEN; - const ALLOWED = process.env[config.credentials.allowed_env] - ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + const ALLOWED = process.env[config.allowed_env] + ? process.env[config.allowed_env].split(",").map((s) => s.trim()) : null; if (!APP_TOKEN) { diff --git a/scripts/adapters/messaging/telegram.js b/scripts/adapters/messaging/telegram.js index d979d6bdd..53de42ade 100644 --- a/scripts/adapters/messaging/telegram.js +++ b/scripts/adapters/messaging/telegram.js @@ -10,9 +10,9 @@ const https = require("https"); module.exports = function createAdapter(config) { - const TOKEN = process.env[config.credentials.token_env]; - const ALLOWED = process.env[config.credentials.allowed_env] - ? process.env[config.credentials.allowed_env].split(",").map((s) => s.trim()) + const TOKEN = process.env[config.credential_env]; + const ALLOWED = process.env[config.allowed_env] + ? process.env[config.allowed_env].split(",").map((s) => s.trim()) : null; let offset = 0; diff --git a/scripts/bridge.js b/scripts/bridge.js index 889f1c46b..fb66c5ecf 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -25,27 +25,24 @@ const { runAgentInSandbox, SANDBOX } = require("./bridge-core"); const yaml = require("js-yaml"); -const BRIDGES_DIR = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges"); +const BLUEPRINT_PATH = path.join(__dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"); -// ── Load bridge configs ─────────────────────────────────────────── +// ── Load bridge configs from blueprint.yaml ─────────────────────── function loadBridgeConfigs() { - const configs = []; - if (!fs.existsSync(BRIDGES_DIR)) return configs; - - for (const typeDir of fs.readdirSync(BRIDGES_DIR, { withFileTypes: true })) { - if (!typeDir.isDirectory()) continue; - const typePath = path.join(BRIDGES_DIR, typeDir.name); - - for (const file of fs.readdirSync(typePath)) { - if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue; - const content = fs.readFileSync(path.join(typePath, file), "utf-8"); - const parsed = yaml.load(content); - if (parsed.bridge) configs.push(parsed.bridge); - } + if (!fs.existsSync(BLUEPRINT_PATH)) { + console.error(`Blueprint not found: ${BLUEPRINT_PATH}`); + return []; } - return configs; + const blueprint = yaml.load(fs.readFileSync(BLUEPRINT_PATH, "utf-8")); + const bridges = blueprint.components?.bridges; + if (!bridges) return []; + + return Object.entries(bridges).map(([name, config]) => ({ + name, + ...config, + })); } function findAdapter(config) { @@ -60,7 +57,7 @@ function findAdapter(config) { // ── Message flow engine ─────────────────────────────────────────── async function runBridge(config) { - const tokenEnv = config.credentials.token_env; + const tokenEnv = config.credential_env; const token = process.env[tokenEnv]; if (!token) { console.error(`${tokenEnv} required for ${config.name} bridge`); @@ -68,7 +65,7 @@ async function runBridge(config) { } // Check extra required env vars (e.g., SLACK_APP_TOKEN) - const extraEnvs = config.credentials.extra_env; + const extraEnvs = config.extra_credential_env; if (Array.isArray(extraEnvs)) { for (const env of extraEnvs) { if (!process.env[env]) { @@ -82,8 +79,8 @@ async function runBridge(config) { if (!createAdapter) process.exit(1); const adapter = createAdapter(config); - const prefix = config.messaging.session_prefix; - const maxChunk = config.messaging.max_chunk_size; + const prefix = config.session_prefix; + const maxChunk = config.max_chunk_size; async function onMessage(msg) { console.log(`[${config.name}] [${msg.channelId}] ${msg.userName}: inbound (len=${msg.text.length})`); @@ -136,8 +133,8 @@ if (args[0] === "--list") { const configs = loadBridgeConfigs(); console.log("\nAvailable bridges:\n"); for (const c of configs) { - const token = process.env[c.credentials.token_env] ? "✓" : "✗"; - console.log(` ${token} ${c.name.padEnd(12)} ${c.description} (${c.credentials.token_env})`); + const token = process.env[c.credential_env] ? "✓" : "✗"; + console.log(` ${token} ${c.name.padEnd(12)} ${c.type} bridge (${c.credential_env})`); } console.log(""); process.exit(0); diff --git a/test/runner.test.js b/test/runner.test.js index 310226ba9..bc74ec868 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -225,15 +225,16 @@ describe("runner helpers", () => { } }); - it("each messaging bridge has a YAML config", () => { + it("blueprint.yaml defines bridge configs for all messaging platforms", () => { const fs = require("fs"); - const bridgesDir = path.join(__dirname, "..", "nemoclaw-blueprint", "bridges", "messaging"); + const yaml = require("js-yaml"); + const bp = yaml.load(fs.readFileSync(path.join(__dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"), "utf-8")); + const bridges = bp.components.bridges; for (const name of ["telegram", "discord", "slack"]) { - const yamlPath = path.join(bridgesDir, `${name}.yaml`); - assert.ok(fs.existsSync(yamlPath), `bridge config ${name}.yaml must exist`); - const src = fs.readFileSync(yamlPath, "utf-8"); - assert.ok(src.includes("token_env:"), `${name}.yaml must specify token_env`); - assert.ok(src.includes("session_prefix:"), `${name}.yaml must specify session_prefix`); + assert.ok(bridges[name], `blueprint must define ${name} bridge`); + assert.ok(bridges[name].credential_env, `${name} bridge must specify credential_env`); + assert.ok(bridges[name].session_prefix, `${name} bridge must specify session_prefix`); + assert.ok(bridges[name].adapter, `${name} bridge must specify adapter`); } }); }); From 2690fab50606225997675ad66771c3a1e76c6817 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:42:45 -0700 Subject: [PATCH 09/15] fix: add backwards-compatible telegram-bridge.js wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users and scripts that reference scripts/telegram-bridge.js directly continue to work — the wrapper injects "telegram" into argv and delegates to the generic bridge runner. --- scripts/telegram-bridge.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 scripts/telegram-bridge.js diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js new file mode 100644 index 000000000..40f230e80 --- /dev/null +++ b/scripts/telegram-bridge.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Backwards-compatible wrapper — delegates to the generic bridge runner. +// Users and scripts that reference this path directly will continue to work. + +process.argv.splice(2, 0, "telegram"); +require("./bridge"); From 09bc8111fee379c8f640d92d1c210d79c55a988e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:51:08 -0700 Subject: [PATCH 10/15] fix: auto-start messaging bridges after onboard when tokens detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RISKY CHANGE — migration path to standardize messaging configuration. When messaging tokens (Telegram, Discord, Slack) are detected at onboard completion, automatically start the host-side bridges via start-services.sh. This seamlessly migrates users from the in-sandbox OpenClaw plugin path (#601) to the host-side bridge architecture. Discord and Slack enforce single gateway connections per token, so the host bridge naturally takes over from the in-sandbox plugin. The env var passthrough is kept for backwards compatibility during this transition. --- bin/lib/onboard.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a106285c5..fad05fd7b 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -963,9 +963,33 @@ async function onboard(opts = {}) { await setupInference(sandboxName, model, provider); await setupOpenclaw(sandboxName, model, provider); await setupPolicies(sandboxName); + startMessagingBridges(sandboxName); printDashboard(sandboxName, model, provider); } +// ── Auto-start messaging bridges ──────────────────────────────── +// RISKY CHANGE: This is a migration path to standardize messaging +// configuration. We auto-start host-side bridges when tokens are +// detected, which takes over from the in-sandbox OpenClaw plugin +// (Discord/Slack enforce single gateway connections per token). +// The in-sandbox env var passthrough (#601) is kept for backwards +// compatibility during this transition. + +function startMessagingBridges(sandboxName) { + const hasMessagingToken = + getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN || + getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN || + getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; + + if (!hasMessagingToken) return; + + console.log(""); + console.log(" Starting messaging bridges..."); + const safeName = sandboxName && /^[a-zA-Z0-9._-]+$/.test(sandboxName) ? sandboxName : null; + const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; + run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`, { ignoreError: true }); +} + module.exports = { buildSandboxConfigSyncScript, getInstalledOpenshellVersion, From c729c1443adbd4cd8d02e742cb90431b8f218e6c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 19:54:52 -0700 Subject: [PATCH 11/15] test: add regression tests for bridge architecture and migration path Tests cover: - blueprint.yaml bridge config schema and credential_env naming - slack extra_credential_env array parsing (the js-yaml fix) - telegram-bridge.js backwards-compat wrapper delegation - bridge.js reads from blueprint.yaml, not separate files - bridge.js logs metadata only (no raw message content) - onboard auto-starts bridges with risk annotation - all four credential types use getCredential pattern --- test/runner.test.js | 65 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/runner.test.js b/test/runner.test.js index bc74ec868..b44accfbc 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -237,5 +237,70 @@ describe("runner helpers", () => { assert.ok(bridges[name].adapter, `${name} bridge must specify adapter`); } }); + + it("blueprint bridge configs use credential_env naming consistent with inference profiles", () => { + const fs = require("fs"); + const yaml = require("js-yaml"); + const bp = yaml.load(fs.readFileSync(path.join(__dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"), "utf-8")); + const bridges = bp.components.bridges; + // Verify field naming matches inference profile convention (credential_env, not token_env) + for (const [name, config] of Object.entries(bridges)) { + assert.ok(!config.token_env, `${name} bridge should use credential_env, not token_env`); + assert.equal(typeof config.credential_env, "string", `${name} bridge credential_env must be a string`); + } + }); + + it("slack bridge config lists SLACK_APP_TOKEN in extra_credential_env", () => { + const fs = require("fs"); + const yaml = require("js-yaml"); + const bp = yaml.load(fs.readFileSync(path.join(__dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"), "utf-8")); + const slack = bp.components.bridges.slack; + assert.ok(Array.isArray(slack.extra_credential_env), "slack must have extra_credential_env array"); + assert.ok(slack.extra_credential_env.includes("SLACK_APP_TOKEN"), "slack must require SLACK_APP_TOKEN"); + }); + + it("telegram-bridge.js backwards-compat wrapper delegates to bridge.js", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + assert.ok(src.includes("require(\"./bridge\")"), "telegram-bridge.js must delegate to bridge.js"); + assert.ok(src.includes("telegram"), "telegram-bridge.js must inject 'telegram' arg"); + }); + + it("bridge.js loads configs from blueprint.yaml, not separate files", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge.js"), "utf-8"); + assert.ok(src.includes("blueprint.yaml"), "bridge.js must reference blueprint.yaml"); + assert.ok(!src.includes("bridges/messaging"), "bridge.js must not reference separate bridge files"); + }); + + it("bridge.js logs metadata only, never raw message content", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge.js"), "utf-8"); + // Ensure log lines use length, not content + assert.ok(src.includes("inbound (len="), "bridge.js must log message length, not content"); + assert.ok(src.includes("response (len="), "bridge.js must log response length, not content"); + // Ensure console.log calls never interpolate raw msg.text (length is ok) + const logLines = src.split("\n").filter((l) => l.includes("console.log")); + for (const line of logLines) { + const hasRawText = line.includes("msg.text}") || line.includes("msg.text,") || line.includes("msg.text)"); + assert.ok(!hasRawText, `log line must not include raw text: ${line.trim()}`); + } + }); + + it("onboard auto-starts bridges when messaging tokens detected", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + assert.ok(src.includes("startMessagingBridges"), "onboard must call startMessagingBridges"); + assert.ok(src.includes("start-services.sh"), "startMessagingBridges must delegate to start-services.sh"); + assert.ok(src.includes("RISKY CHANGE"), "auto-start must be annotated as risky migration"); + }); + + it("onboard passes all four credential types via getCredential pattern", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "bin", "lib", "onboard.js"), "utf-8"); + for (const token of ["NVIDIA_API_KEY", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"]) { + assert.ok(src.includes(`getCredential("${token}")`), `onboard must use getCredential for ${token}`); + } + }); }); }); From 44a0fe0e46a357a9726954a69eb7bf8c43a638e4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 20:13:39 -0700 Subject: [PATCH 12/15] fix: catch execFileSync failure in bridge-core sandbox relay If openshell sandbox ssh-config fails (sandbox not running), the synchronous throw inside the Promise constructor would crash the bridge. Now catches and resolves with a descriptive error message. --- scripts/bridge-core.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/bridge-core.js b/scripts/bridge-core.js index 08f0b8732..5847d3dcd 100644 --- a/scripts/bridge-core.js +++ b/scripts/bridge-core.js @@ -48,9 +48,15 @@ try { */ function runAgentInSandbox(message, sessionId) { return new Promise((resolve) => { - const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { - encoding: "utf-8", - }); + let sshConfig; + try { + sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { + encoding: "utf-8", + }); + } catch (err) { + resolve(`Error: sandbox SSH config failed — is '${SANDBOX}' running? ${err.message || err}`); + return; + } const confDir = fs.mkdtempSync("/tmp/nemoclaw-bridge-ssh-"); const confPath = `${confDir}/config`; From 8143bfd40890ff2fcc5bc837490cb70c20a16e7e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 20:43:31 -0700 Subject: [PATCH 13/15] fix: address CodeRabbit round 4 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export file-backed credentials to process.env before calling start-services.sh so bridges see tokens from credentials.json - Use per-platform allowlist env vars (ALLOWED_DISCORD_CHANNEL_IDS, ALLOWED_SLACK_CHANNEL_IDS, ALLOWED_TELEGRAM_CHAT_IDS) to prevent cross-platform filter collision - Add client-side socket timeout (60s) to Telegram HTTP adapter to prevent hung connections from blocking the poll loop forever - Show all required env vars in bridge --list (Slack shows both SLACK_BOT_TOKEN and SLACK_APP_TOKEN, marks ✗ if either missing) - Keep backwards compat: Telegram adapter falls back to legacy ALLOWED_CHAT_IDS env var --- bin/lib/onboard.js | 16 +++++++++++++--- nemoclaw-blueprint/blueprint.yaml | 5 +++-- scripts/adapters/messaging/telegram.js | 7 ++++--- scripts/bridge.js | 8 ++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index fad05fd7b..dba14ba73 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -976,10 +976,20 @@ async function onboard(opts = {}) { // compatibility during this transition. function startMessagingBridges(sandboxName) { + // Rehydrate file-backed credentials into process.env so start-services.sh + // and bridge.js can see them (they only check process.env, not credentials.json). + const bridgeTokens = ["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"]; + for (const key of bridgeTokens) { + if (!process.env[key]) { + const val = getCredential(key); + if (val) process.env[key] = val; + } + } + const hasMessagingToken = - getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN || - getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN || - getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN || + process.env.DISCORD_BOT_TOKEN || + process.env.SLACK_BOT_TOKEN; if (!hasMessagingToken) return; diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index a6a20c106..295de9fca 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -59,6 +59,7 @@ components: type: messaging adapter: telegram credential_env: "TELEGRAM_BOT_TOKEN" + allowed_env: "ALLOWED_TELEGRAM_CHAT_IDS" session_prefix: tg max_chunk_size: 4000 @@ -66,7 +67,7 @@ components: type: messaging adapter: discord credential_env: "DISCORD_BOT_TOKEN" - allowed_env: "ALLOWED_CHANNEL_IDS" + allowed_env: "ALLOWED_DISCORD_CHANNEL_IDS" session_prefix: dc max_chunk_size: 1900 @@ -76,7 +77,7 @@ components: credential_env: "SLACK_BOT_TOKEN" extra_credential_env: - "SLACK_APP_TOKEN" - allowed_env: "ALLOWED_CHANNEL_IDS" + allowed_env: "ALLOWED_SLACK_CHANNEL_IDS" session_prefix: sl max_chunk_size: 3000 diff --git a/scripts/adapters/messaging/telegram.js b/scripts/adapters/messaging/telegram.js index 53de42ade..772b43aef 100644 --- a/scripts/adapters/messaging/telegram.js +++ b/scripts/adapters/messaging/telegram.js @@ -11,9 +11,9 @@ const https = require("https"); module.exports = function createAdapter(config) { const TOKEN = process.env[config.credential_env]; - const ALLOWED = process.env[config.allowed_env] - ? process.env[config.allowed_env].split(",").map((s) => s.trim()) - : null; + // Support legacy ALLOWED_CHAT_IDS for backwards compatibility + const allowedRaw = process.env[config.allowed_env] || process.env.ALLOWED_CHAT_IDS; + const ALLOWED = allowedRaw ? allowedRaw.split(",").map((s) => s.trim()) : null; let offset = 0; @@ -35,6 +35,7 @@ module.exports = function createAdapter(config) { }); }, ); + req.setTimeout(60000, () => req.destroy(new Error("Telegram API request timed out"))); req.on("error", reject); req.write(data); req.end(); diff --git a/scripts/bridge.js b/scripts/bridge.js index fb66c5ecf..968de1b82 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -133,8 +133,12 @@ if (args[0] === "--list") { const configs = loadBridgeConfigs(); console.log("\nAvailable bridges:\n"); for (const c of configs) { - const token = process.env[c.credential_env] ? "✓" : "✗"; - console.log(` ${token} ${c.name.padEnd(12)} ${c.type} bridge (${c.credential_env})`); + const hasMain = !!process.env[c.credential_env]; + const extraEnvs = Array.isArray(c.extra_credential_env) ? c.extra_credential_env : []; + const hasAll = hasMain && extraEnvs.every((e) => !!process.env[e]); + const status = hasAll ? "✓" : "✗"; + const envList = [c.credential_env, ...extraEnvs].join(", "); + console.log(` ${status} ${c.name.padEnd(12)} ${c.type} bridge (${envList})`); } console.log(""); process.exit(0); From 1fa10d3153650182880e45de0aeb748df6a22c11 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 22 Mar 2026 15:28:20 -0700 Subject: [PATCH 14/15] fix: reject tgApi promise on Telegram ok:false responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tgApi always resolved regardless of Telegram's ok field, so the Markdown retry fallback in reply() never fired on API errors like parse failures — only on network errors. Now rejects on ok:false, enabling the plain-text retry path. Addresses cv review feedback on #617. --- scripts/adapters/messaging/telegram.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/adapters/messaging/telegram.js b/scripts/adapters/messaging/telegram.js index 772b43aef..eb10eb6bd 100644 --- a/scripts/adapters/messaging/telegram.js +++ b/scripts/adapters/messaging/telegram.js @@ -31,7 +31,10 @@ module.exports = function createAdapter(config) { let buf = ""; res.on("data", (c) => (buf += c)); res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } + try { + const json = JSON.parse(buf); + if (json.ok) resolve(json); else reject(new Error(json.description || "Telegram API error")); + } catch { reject(new Error(`Unparseable Telegram response: ${buf.slice(0, 200)}`)); } }); }, ); @@ -46,9 +49,11 @@ module.exports = function createAdapter(config) { name: "telegram", async start(onMessage) { - const me = await tgApi("getMe", {}); - if (!me.ok) { - throw new Error(`Failed to connect to Telegram: ${JSON.stringify(me)}`); + let me; + try { + me = await tgApi("getMe", {}); + } catch (err) { + throw new Error(`Failed to connect to Telegram: ${err.message}`); } const botName = `@${me.result.username}`; From 29a46963340933600972b067340e9cc2c2e95103 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 22 Mar 2026 15:49:48 -0700 Subject: [PATCH 15/15] fix: preserve /start and /reset bot commands in telegram adapter The old monolithic telegram-bridge.js handled these locally. Without them, the commands get forwarded to the agent as prompts. --- scripts/adapters/messaging/telegram.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/adapters/messaging/telegram.js b/scripts/adapters/messaging/telegram.js index eb10eb6bd..4b2caaff8 100644 --- a/scripts/adapters/messaging/telegram.js +++ b/scripts/adapters/messaging/telegram.js @@ -72,6 +72,25 @@ module.exports = function createAdapter(config) { const userName = msg.from?.first_name || "someone"; + // Handle bot commands locally — these existed in the + // monolithic telegram-bridge.js and users may depend on them. + if (msg.text === "/start") { + await tgApi("sendMessage", { + chat_id: channelId, + text: "NemoClaw — powered by Nemotron\n\nSend me a message and I'll run it through the OpenClaw agent inside an OpenShell sandbox.", + reply_to_message_id: msg.message_id, + }).catch(() => {}); + continue; + } + if (msg.text === "/reset") { + await tgApi("sendMessage", { + chat_id: channelId, + text: "Session reset.", + reply_to_message_id: msg.message_id, + }).catch(() => {}); + continue; + } + await onMessage({ channelId, userName,