diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 59f2527f1..c1268567c 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1865,6 +1865,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, // See: crates/openshell-sandbox/src/proxy.rs (header stripping), // crates/openshell-router/src/backend.rs (server-side auth injection). const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; + const sandboxEnv = { ...process.env }; delete sandboxEnv.NVIDIA_API_KEY; const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; @@ -1875,6 +1876,14 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, if (slackToken) { sandboxEnv.SLACK_BOT_TOKEN = slackToken; } + const slackAppToken = getCredential("SLACK_APP_TOKEN") || process.env.SLACK_APP_TOKEN; + if (slackAppToken) { + sandboxEnv.SLACK_APP_TOKEN = slackAppToken; + } + const slackGateway = getCredential("NEMOCLAW_OPENCLAW_SLACK_GATEWAY") || process.env.NEMOCLAW_OPENCLAW_SLACK_GATEWAY; + if (slackGateway) { + sandboxEnv.NEMOCLAW_OPENCLAW_SLACK_GATEWAY = slackGateway; + } // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline // command (awk, always 0) unless pipefail is set. Removing the pipe diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 77118bdd4..bb06d1b32 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -468,6 +468,12 @@ async function deploy(instanceName) { async function start() { await ensureApiKey(); + + const creds = require("./lib/credentials").loadCredentials(); + for (const [k, v] of Object.entries(creds)) { + if (!process.env[k]) process.env[k] = v; + } + const { defaultSandbox } = registry.listSandboxes(); const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; @@ -475,7 +481,10 @@ async function start() { } function stop() { - run(`bash "${SCRIPTS}/start-services.sh" --stop`); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; + run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh" --stop`); } function debug(args) { @@ -533,7 +542,10 @@ function showStatus() { } // Show service status - run(`bash "${SCRIPTS}/start-services.sh" --status`); + const { defaultSandbox: ds2 } = registry.listSandboxes(); + const sn2 = ds2 && /^[a-zA-Z0-9._-]+$/.test(ds2) ? ds2 : null; + const se2 = sn2 ? `SANDBOX_NAME=${shellQuote(sn2)}` : ""; + run(`${se2} bash "${SCRIPTS}/start-services.sh" --status`); } function listSandboxes() { diff --git a/scripts/slack-bridge.js b/scripts/slack-bridge.js new file mode 100755 index 000000000..15d1158f9 --- /dev/null +++ b/scripts/slack-bridge.js @@ -0,0 +1,303 @@ +#!/usr/bin/env node +/* global WebSocket */ +// 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. + * + * Env: + * SLACK_APP_TOKEN — xapp-... + * SLACK_BOT_TOKEN — xoxb-... + * NVIDIA_API_KEY — for inference + * SANDBOX_NAME — sandbox name (default: nemoclaw) + */ + +const https = require("https"); +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 APP_TOKEN = process.env.SLACK_APP_TOKEN; +const BOT_TOKEN = process.env.SLACK_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 (!APP_TOKEN || !BOT_TOKEN) { console.error("SLACK_APP_TOKEN and SLACK_BOT_TOKEN required"); process.exit(1); } +if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } + +const COOLDOWN_MS = 5000; +const lastMessageTime = new Map(); +const busyChats = new Set(); + + +// ── Slack API helpers ───────────────────────────────────────────── + +function slackApi(method, body, token) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = https.request( + { + hostname: "slack.com", + path: `/api/${method}`, + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + "Authorization": `Bearer ${token}`, + "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(channel, text, thread_ts) { + const chunks = []; + for (let i = 0; i < text.length; i += 3000) { + chunks.push(text.slice(i, i + 3000)); + } + for (const chunk of chunks) { + try { + const res = await slackApi("chat.postMessage", { + channel, + text: chunk, + thread_ts, + }, BOT_TOKEN); + if (!res.ok) { + console.error(`Failed to send message to ${channel}: ${res.error}`); + } + } catch (err) { + console.error(`Error sending message to ${channel}: ${err.message}`); + throw err; + } + } +} + +// ── Run agent inside sandbox ────────────────────────────────────── + +function runAgentInSandbox(message, sessionId) { + return new Promise((resolve) => { + let sshConfig; + try { + sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" }); + } catch (err) { + resolve(`Failed to get SSH config for sandbox '${SANDBOX}': ${err.message}`); + return; + } + + const confDir = fs.mkdtempSync("/tmp/nemoclaw-slack-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("slack-" + safeSessionId)}`; + + const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { + stdio: ["ignore", "pipe", "pipe"], + }); + + let killed = false; + const timeoutId = setTimeout(() => { + killed = true; + 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(timeoutId); + try { fs.unlinkSync(confPath); fs.rmdirSync(confDir); } catch { /* ignored */ } + + 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 (killed) { + resolve("Agent request timed out after 120 seconds."); + return; + } + + 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}`); + }); + }); +} + +// ── Socket Mode ─────────────────────────────────────────────────── + +async function connectSocketMode() { + const res = await slackApi("apps.connections.open", {}, APP_TOKEN); + if (!res.ok) { + console.error("Failed to open socket mode connection:", res); + process.exit(1); + } + + const ws = new WebSocket(res.url); + + let reconnectAttempts = 0; + const MAX_RECONNECT_DELAY = 60000; + + ws.addEventListener("open", () => { + reconnectAttempts = 0; + console.log("Connected to Slack Socket Mode."); + }); + + ws.addEventListener("message", async (event) => { + let msg; + try { + msg = JSON.parse(event.data); + } catch (err) { + console.error("Failed to parse WebSocket message:", err.message); + return; + } + + if (msg.type === "hello") return; + + if (msg.envelope_id) { + ws.send(JSON.stringify({ envelope_id: msg.envelope_id })); + } + + if (msg.type === "events_api" && msg.payload && msg.payload.event) { + const slackEvent = msg.payload.event; + + // Ignore bot messages + if (slackEvent.bot_id || slackEvent.subtype === "bot_message") return; + + if (slackEvent.type === "message" || slackEvent.type === "app_mention") { + const text = slackEvent.text || ""; + // If app_mention, strip the mention + const cleanText = text.replace(/<@[A-Z0-9]+>/g, "").trim(); + if (!cleanText) return; + + const channel = slackEvent.channel; + const thread_ts = slackEvent.thread_ts || slackEvent.ts; + const sessionId = thread_ts; // Use thread as session + + console.log(`[${channel}] ${slackEvent.user}: ${cleanText}`); + + if (cleanText === "reset") { + await sendMessage(channel, "Session reset.", thread_ts); + return; + } + + const now = Date.now(); + const lastTime = lastMessageTime.get(channel) || 0; + if (now - lastTime < COOLDOWN_MS) { + const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); + await sendMessage(channel, `Please wait ${wait}s before sending another message.`, thread_ts); + return; + } + + if (busyChats.has(channel)) { + await sendMessage(channel, "Still processing your previous message.", thread_ts); + return; + } + + lastMessageTime.set(channel, now); + busyChats.add(channel); + + try { + const response = await runAgentInSandbox(cleanText, sessionId); + console.log(`[${channel}] agent: ${response.slice(0, 100)}...`); + await sendMessage(channel, response, thread_ts); + } catch (err) { + await sendMessage(channel, `Error: ${err.message}`, thread_ts); + } finally { + busyChats.delete(channel); + } + } + } + }); + + ws.addEventListener("close", () => { + console.log("Socket Mode connection closed. Reconnecting..."); + const delay = Math.min(3000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY); + reconnectAttempts++; + setTimeout(connectSocketMode, delay); + }); + + ws.addEventListener("error", (err) => { + console.error("WebSocket error:", err); + }); +} + +// ── Main ────────────────────────────────────────────────────────── + +async function main() { + const authTest = await slackApi("auth.test", {}, BOT_TOKEN); + if (!authTest.ok) { + console.error("Failed to authenticate with Slack:", authTest); + process.exit(1); + } + + + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Slack Bridge │"); + console.log(" │ │"); + console.log(` │ Bot: @${(authTest.user + " ").slice(0, 37)}│`); + console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); + const modelName = process.env.NEMOCLAW_MODEL || "unknown"; + console.log(` │ Model: ${(modelName + " ").slice(0, 39)}│`); + 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(""); + + connectSocketMode(); +} + +process.on("unhandledRejection", (err) => { + console.error("Unhandled rejection:", err); +}); + +main(); diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 797052049..6e7421960 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 slack-bridge cloudflared; do if is_running "$svc"; then echo -e " ${GREEN}●${NC} $svc (PID $(cat "$PIDDIR/$svc.pid"))" else @@ -119,6 +119,7 @@ do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared stop_service telegram-bridge + stop_service slack-bridge info "All services stopped." } @@ -156,6 +157,12 @@ do_start() { node "$REPO_DIR/scripts/telegram-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 + # 3. cloudflared tunnel if command -v cloudflared >/dev/null 2>&1; then start_service cloudflared \ @@ -198,6 +205,12 @@ do_start() { echo " │ Telegram: not started (no token) │" fi + if is_running slack-bridge; then + echo " │ Slack: bridge running │" + else + echo " │ Slack: not started (missing tokens) │" + fi + echo " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘"