Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2380,6 +2380,7 @@ async function createSandbox(
// 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;
Expand All @@ -2390,6 +2391,14 @@ async function createSandbox(
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
Expand Down
17 changes: 15 additions & 2 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,13 @@ 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;
Expand All @@ -776,7 +783,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) {
Expand Down Expand Up @@ -851,7 +861,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`);
}

async function listSandboxes() {
Expand Down
303 changes: 303 additions & 0 deletions scripts/slack-bridge.js
Original file line number Diff line number Diff line change
@@ -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();
Loading