diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ace8d6e97..ff9a84c77 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -555,9 +555,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) { @@ -567,6 +573,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 @@ -1091,9 +1101,43 @@ 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) { + // 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 = + process.env.TELEGRAM_BOT_TOKEN || + process.env.DISCORD_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, getFutureShellPathHint, diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 97f69f800..69b5c5134 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. diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index f55f9f651..295de9fca 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -54,6 +54,33 @@ components: credential_env: "OPENAI_API_KEY" credential_default: "dummy" + bridges: + telegram: + type: messaging + adapter: telegram + credential_env: "TELEGRAM_BOT_TOKEN" + allowed_env: "ALLOWED_TELEGRAM_CHAT_IDS" + session_prefix: tg + max_chunk_size: 4000 + + discord: + type: messaging + adapter: discord + credential_env: "DISCORD_BOT_TOKEN" + allowed_env: "ALLOWED_DISCORD_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_SLACK_CHANNEL_IDS" + session_prefix: sl + max_chunk_size: 3000 + policy: base: "sandboxes/openclaw/policy.yaml" additions: diff --git a/package-lock.json b/package-lock.json index 1d54cdc1f..78ff2164d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@slack/bolt": "^4.6.0", + "discord.js": "^14.25.1", "openclaw": "2026.3.11" }, "bin": { @@ -1313,6 +1315,120 @@ } } }, + "node_modules/@discordjs/builders": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz", + "integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==", + "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.40", + "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.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "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/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "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", @@ -1359,6 +1475,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", @@ -3953,6 +4104,39 @@ "dev": true, "license": "MIT" }, + "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", @@ -5407,6 +5591,16 @@ "url": "https://opencollective.com/vitest" } }, + "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", @@ -6626,6 +6820,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", @@ -8807,6 +9037,12 @@ "uc.micro": "^2.0.0" } }, + "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", @@ -8899,7 +9135,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": { @@ -8964,6 +9199,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/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11450,6 +11691,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 cbbe0bc3d..58f4e268e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "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" }, "files": [ diff --git a/scripts/adapters/messaging/discord.js b/scripts/adapters/messaging/discord.js new file mode 100644 index 000000000..99c294abc --- /dev/null +++ b/scripts/adapters/messaging/discord.js @@ -0,0 +1,58 @@ +// 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, Partials } = require("discord.js"); + +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; + + return { + name: "discord", + + start(onMessage) { + return new Promise((resolve, reject) => { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], + }); + + 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..a3acfece8 --- /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.credential_env]; + const APP_TOKEN = process.env.SLACK_APP_TOKEN; + const ALLOWED = process.env[config.allowed_env] + ? process.env[config.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..4b2caaff8 --- /dev/null +++ b/scripts/adapters/messaging/telegram.js @@ -0,0 +1,124 @@ +// 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.credential_env]; + // 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; + + 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 { + 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)}`)); } + }); + }, + ); + req.setTimeout(60000, () => req.destroy(new Error("Telegram API request timed out"))); + req.on("error", reject); + req.write(data); + req.end(); + }); + } + + return { + name: "telegram", + + async start(onMessage) { + let me; + try { + me = await tgApi("getMe", {}); + } catch (err) { + throw new Error(`Failed to connect to Telegram: ${err.message}`); + } + + 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"; + + // 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, + 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-core.js b/scripts/bridge-core.js new file mode 100644 index 000000000..5847d3dcd --- /dev/null +++ b/scripts/bridge-core.js @@ -0,0 +1,133 @@ +#!/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) => { + 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`; + 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], { + stdio: ["ignore", "pipe", "pipe"], + }); + + // spawn() does not support timeout — implement manually + const killTimer = setTimeout(() => { + proc.kill("SIGTERM"); + }, 120000); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + + proc.on("close", (code) => { + clearTimeout(killTimer); + 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) => { + clearTimeout(killTimer); + resolve(`Error: ${err && err.message ? err.message : String(err)}`); + }); + }); +} + +module.exports = { + runAgentInSandbox, + SANDBOX, + API_KEY, + OPENSHELL, + shellQuote, + validateName, +}; diff --git a/scripts/bridge.js b/scripts/bridge.js new file mode 100644 index 000000000..968de1b82 --- /dev/null +++ b/scripts/bridge.js @@ -0,0 +1,161 @@ +#!/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 yaml = require("js-yaml"); + +const BLUEPRINT_PATH = path.join(__dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"); + +// ── Load bridge configs from blueprint.yaml ─────────────────────── + +function loadBridgeConfigs() { + if (!fs.existsSync(BLUEPRINT_PATH)) { + console.error(`Blueprint not found: ${BLUEPRINT_PATH}`); + return []; + } + + 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) { + 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.credential_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.extra_credential_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.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})`); + + // 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 (len=${response.length})`); + + // 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); + const errorMsg = err && err.message ? err.message : String(err); + await msg.reply(`Error: ${errorMsg}`).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 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); +} + +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/start-services.sh b/scripts/start-services.sh index 303caf696..c63d6fba5 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -97,7 +97,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 @@ -118,6 +118,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." } @@ -141,13 +143,21 @@ 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 + if [ -n "${DISCORD_BOT_TOKEN:-}" ]; then + SANDBOX_NAME="$SANDBOX_NAME" start_service discord-bridge \ + node "$REPO_DIR/scripts/bridge.js" discord + fi + if [ -n "${SLACK_BOT_TOKEN:-}" ] && [ -n "${SLACK_APP_TOKEN:-}" ]; then + SANDBOX_NAME="$SANDBOX_NAME" start_service slack-bridge \ + node "$REPO_DIR/scripts/bridge.js" slack fi - # 3. cloudflared tunnel + # 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 old mode 100755 new mode 100644 index e885b09f2..40f230e80 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -2,252 +2,8 @@ // 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) - */ +// Backwards-compatible wrapper — delegates to the generic bridge runner. +// Users and scripts that reference this path directly will continue to work. -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 TOKEN = process.env.TELEGRAM_BOT_TOKEN; -const API_KEY = process.env.NVIDIA_API_KEY; -const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; -try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); } -const 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 - -// ── Telegram API helpers ────────────────────────────────────────── - -function tgApi(method, body) { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const req = https.request( - { - hostname: "api.telegram.org", - path: `/bot${TOKEN}/${method}`, - method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, - }, - (res) => { - let buf = ""; - res.on("data", (c) => (buf += c)); - res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } - }); - }, - ); - req.on("error", reject); - req.write(data); - req.end(); - }); -} - -async function sendMessage(chatId, text, replyTo) { - // Telegram max message length is 4096 - const chunks = []; - for (let i = 0; i < text.length; i += 4000) { - chunks.push(text.slice(i, i + 4000)); - } - for (const chunk of chunks) { - await tgApi("sendMessage", { - chat_id: chatId, - text: chunk, - reply_to_message_id: replyTo, - parse_mode: "Markdown", - }).catch(() => - // Retry without markdown if it fails (unbalanced formatting) - tgApi("sendMessage", { chat_id: chatId, text: chunk, reply_to_message_id: replyTo }), - ); - } -} - -async function sendTyping(chatId) { - await tgApi("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}); -} - -// ── Run agent inside sandbox ────────────────────────────────────── - -function runAgentInSandbox(message, sessionId) { - return new Promise((resolve) => { - const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" }); - - // Write temp ssh config with unpredictable name - const confDir = require("fs").mkdtempSync("/tmp/nemoclaw-tg-ssh-"); - const confPath = `${confDir}/config`; - require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 }); - - // Pass message and API key via stdin to avoid shell interpolation. - // The remote command reads them from environment/stdin rather than - // embedding user content in a shell string. - const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9-]/g, ""); - const cmd = `export NVIDIA_API_KEY=${shellQuote(API_KEY)} && nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`; - - const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { - timeout: 120000, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - proc.stdout.on("data", (d) => (stdout += d.toString())); - proc.stderr.on("data", (d) => (stderr += d.toString())); - - proc.on("close", (code) => { - try { require("fs").unlinkSync(confPath); require("fs").rmdirSync(confDir); } catch {} - - // Extract the actual agent response — skip setup lines - const lines = stdout.split("\n"); - const responseLines = lines.filter( - (l) => - !l.startsWith("Setting up NemoClaw") && - !l.startsWith("[plugins]") && - !l.startsWith("(node:") && - !l.includes("NemoClaw ready") && - !l.includes("NemoClaw registered") && - !l.includes("openclaw agent") && - !l.includes("┌─") && - !l.includes("│ ") && - !l.includes("└─") && - l.trim() !== "", - ); - - const response = responseLines.join("\n").trim(); - - if (response) { - resolve(response); - } else if (code !== 0) { - resolve(`Agent exited with code ${code}. ${stderr.trim().slice(0, 500)}`); - } else { - resolve("(no response)"); - } - }); - - proc.on("error", (err) => { - resolve(`Error: ${err.message}`); - }); - }); -} - -// ── Poll loop ───────────────────────────────────────────────────── - -async function poll() { - try { - const res = await tgApi("getUpdates", { offset, timeout: 30 }); - - if (res.ok && res.result?.length > 0) { - for (const update of res.result) { - offset = update.update_id + 1; - - const msg = update.message; - if (!msg?.text) continue; - - const chatId = String(msg.chat.id); - - // Access control - if (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, 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(); +process.argv.splice(2, 0, "telegram"); +require("./bridge"); diff --git a/test/runner.test.js b/test/runner.test.js index f35c88256..5beac135d 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -201,11 +201,106 @@ describe("runner helpers", () => { } }); - it("telegram bridge validates SANDBOX_NAME on startup", () => { - - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + it("bridge-core validates SANDBOX_NAME on startup", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge-core.js"), "utf-8"); expect(src.includes("validateName(SANDBOX")).toBeTruthy(); - expect(!src.includes("execSync")).toBeTruthy(); + expect(src.includes("execSync")).toBe(false); + }); + + it("bridge runner uses bridge-core for sandbox relay", () => { + const fs = require("fs"); + const src = fs.readFileSync(path.join(__dirname, "..", "scripts", "bridge.js"), "utf-8"); + expect(src.includes("require(\"./bridge-core\")")).toBeTruthy(); + expect(src.includes("runAgentInSandbox")).toBeTruthy(); + }); + + 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`); + expect(fs.existsSync(adapterPath)).toBeTruthy(); + const src = fs.readFileSync(adapterPath, "utf-8"); + expect(src.includes("module.exports")).toBeTruthy(); + } + }); + + it("blueprint.yaml defines bridge configs for all messaging platforms", () => { + 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; + for (const name of ["telegram", "discord", "slack"]) { + expect(bridges[name]).toBeTruthy(); + expect(bridges[name].credential_env).toBeTruthy(); + expect(bridges[name].session_prefix).toBeTruthy(); + expect(bridges[name].adapter).toBeTruthy(); + } + }); + + 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; + for (const [name, config] of Object.entries(bridges)) { + expect(config.token_env).toBeFalsy(); + expect(typeof config.credential_env).toBe("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; + expect(Array.isArray(slack.extra_credential_env)).toBe(true); + expect(slack.extra_credential_env.includes("SLACK_APP_TOKEN")).toBe(true); + }); + + 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"); + expect(src.includes("require(\"./bridge\")")).toBeTruthy(); + expect(src.includes("telegram")).toBeTruthy(); + }); + + 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"); + expect(src.includes("blueprint.yaml")).toBeTruthy(); + expect(src.includes("bridges/messaging")).toBe(false); + }); + + 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 + expect(src.includes("inbound (len=")).toBeTruthy(); + expect(src.includes("response (len=")).toBeTruthy(); + // 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)"); + expect(hasRawText).toBe(false); + } + }); + + 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"); + expect(src.includes("startMessagingBridges")).toBeTruthy(); + expect(src.includes("start-services.sh")).toBeTruthy(); + expect(src.includes("RISKY CHANGE")).toBeTruthy(); + }); + + 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"]) { + expect(src.includes(`getCredential("${token}")`)).toBeTruthy(); + } }); }); });