Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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;
Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,14 +468,23 @@ 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)}` : "";
run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`);
}

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 @@ -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() {
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