diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 683ba80b8..4e0116110 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -214,11 +214,22 @@ function prompt(question, opts = {}) { }); } +const SUPPORTED_API_KEYS = [ + "NVIDIA_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_API_KEY", +]; + async function ensureApiKey() { - let key = getCredential("NVIDIA_API_KEY"); - if (key) { - process.env.NVIDIA_API_KEY = key; - return; + for (const envKey of SUPPORTED_API_KEYS) { + const val = getCredential(envKey); + if (val) { + process.env[envKey] = val; + return; + } } console.log(""); @@ -232,6 +243,7 @@ async function ensureApiKey() { console.log(" └─────────────────────────────────────────────────────────────────┘"); console.log(""); + let key; while (true) { key = normalizeCredentialValue(await prompt(" NVIDIA API Key: ", { secret: true })); @@ -311,6 +323,7 @@ async function ensureGithubToken() { } const exports_ = { + SUPPORTED_API_KEYS, loadCredentials, normalizeCredentialValue, saveCredential, diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 5ab4e8629..e9d4fce74 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2162,6 +2162,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; @@ -2172,6 +2173,10 @@ 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; + } // 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 63013a67e..93894dbef 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -37,6 +37,7 @@ const { ensureGithubToken, getCredential, isRepoPrivate, + SUPPORTED_API_KEYS, } = require("./lib/credentials"); const registry = require("./lib/registry"); const nim = require("./lib/nim"); @@ -729,7 +730,17 @@ async function deploy(instanceName) { `rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`, ); - const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`]; + const envLines = []; + let hasKey = false; + for (const k of SUPPORTED_API_KEYS) { + const val = process.env[k] || getCredential(k); + if (val) { + envLines.push(`${k}=${shellQuote(val)}`); + hasKey = true; + } + } + if (hasKey) envLines.push("NEMOCLAW_HAS_API_KEY=1"); + const ghToken = process.env.GITHUB_TOKEN; if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`); const tgToken = getCredential("TELEGRAM_BOT_TOKEN"); @@ -782,6 +793,11 @@ async function deploy(instanceName) { } async function start() { + const creds = require("./lib/credentials").loadCredentials(); + for (const [k, v] of Object.entries(creds)) { + if (!process.env[k]) process.env[k] = v; + } + const { startAll } = require("./lib/services"); const { defaultSandbox } = registry.listSandboxes(); const safeName = @@ -885,6 +901,10 @@ function uninstall(args) { } function showStatus() { + if (SUPPORTED_API_KEYS.some((k) => process.env[k] || getCredential(k))) { + process.env.NEMOCLAW_HAS_API_KEY = "1"; + } + // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length > 0) { @@ -902,6 +922,11 @@ function showStatus() { } // Show service status + const creds = require("./lib/credentials").loadCredentials(); + for (const [k, v] of Object.entries(creds)) { + if (!process.env[k]) process.env[k] = v; + } + const { showStatus: showServiceStatus } = require("./lib/services"); showServiceStatus({ sandboxName: defaultSandbox || undefined }); } diff --git a/scripts/slack-bridge.js b/scripts/slack-bridge.js new file mode 100755 index 000000000..72a825c6e --- /dev/null +++ b/scripts/slack-bridge.js @@ -0,0 +1,306 @@ +#!/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 { SUPPORTED_API_KEYS } = require("../bin/lib/credentials"); + +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 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); } + +const hasApiKey = SUPPORTED_API_KEYS.some(k => process.env[k]); +if (!hasApiKey) { console.error("An API key (NVIDIA, OpenAI, Anthropic, etc.) is 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 envExports = SUPPORTED_API_KEYS.filter(k => process.env[k]).map(k => `export ${k}=${shellQuote(process.env[k])}`).join(" && "); + const cmd = `${envExports} && 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 0c64d1341..301e1d9f2 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." } @@ -126,9 +127,12 @@ do_start() { if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." warn "Create a bot via @BotFather on Telegram and set the token." - elif [ -z "${NVIDIA_API_KEY:-}" ]; then - warn "NVIDIA_API_KEY not set — Telegram bridge will not start." - warn "Set NVIDIA_API_KEY if you want Telegram requests to reach inference." + fi + if [ -z "${SLACK_BOT_TOKEN:-}" ] || [ -z "${SLACK_APP_TOKEN:-}" ]; then + warn "SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set — Slack bridge will not start." + fi + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] && { [ -z "${SLACK_BOT_TOKEN:-}" ] || [ -z "${SLACK_APP_TOKEN:-}" ]; }; then + warn "No bot tokens configured — only cloudflared will start." fi command -v node >/dev/null || fail "node not found. Install Node.js first." @@ -152,11 +156,17 @@ do_start() { mkdir -p "$PIDDIR" # Telegram bridge (only if token provided) - if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ]; then + if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ 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 \ @@ -199,6 +209,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 " └─────────────────────────────────────────────────────┘" diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 96a29fd88..0635b04dc 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -20,6 +20,7 @@ const https = require("https"); const { execFileSync, spawn } = require("child_process"); const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); const { shellQuote, validateName } = require("../bin/lib/runner"); +const { SUPPORTED_API_KEYS } = require("../bin/lib/credentials"); const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); const OPENSHELL = resolveOpenshell(); @@ -29,13 +30,14 @@ if (!OPENSHELL) { } 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 = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS); if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } -if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } + +const hasApiKey = SUPPORTED_API_KEYS.some(k => process.env[k]); +if (!hasApiKey) { console.error("An API key (NVIDIA, OpenAI, Anthropic, etc.) is required"); process.exit(1); } let offset = 0; const activeSessions = new Map(); // chatId → message history @@ -104,11 +106,12 @@ function runAgentInSandbox(message, sessionId) { const confPath = `${confDir}/config`; require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 }); - // Pass message and API key via stdin to avoid shell interpolation. + // Pass message and API keys 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 envExports = SUPPORTED_API_KEYS.filter(k => process.env[k]).map(k => `export ${k}=${shellQuote(process.env[k])}`).join(" && "); + const cmd = `${envExports} && 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, diff --git a/src/lib/services.test.ts b/src/lib/services.test.ts index 702438e48..c6d8b4314 100644 --- a/src/lib/services.test.ts +++ b/src/lib/services.test.ts @@ -26,17 +26,18 @@ describe("getServiceStatuses", () => { it("returns stopped status when no PID files exist", () => { const statuses = getServiceStatuses({ pidDir }); - expect(statuses).toHaveLength(2); + expect(statuses).toHaveLength(3); for (const s of statuses) { expect(s.running).toBe(false); expect(s.pid).toBeNull(); } }); - it("returns service names telegram-bridge and cloudflared", () => { + it("returns service names telegram-bridge, slack-bridge, and cloudflared", () => { const statuses = getServiceStatuses({ pidDir }); const names = statuses.map((s) => s.name); expect(names).toContain("telegram-bridge"); + expect(names).toContain("slack-bridge"); expect(names).toContain("cloudflared"); }); @@ -62,7 +63,7 @@ describe("getServiceStatuses", () => { const nested = join(pidDir, "nested", "deep"); const statuses = getServiceStatuses({ pidDir: nested }); expect(existsSync(nested)).toBe(true); - expect(statuses).toHaveLength(2); + expect(statuses).toHaveLength(3); }); }); diff --git a/src/lib/services.ts b/src/lib/services.ts index 9582a5921..72755ee03 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -101,7 +101,7 @@ function removePid(pidDir: string, name: string): void { // Service lifecycle // --------------------------------------------------------------------------- -const SERVICE_NAMES = ["telegram-bridge", "cloudflared"] as const; +const SERVICE_NAMES = ["telegram-bridge", "slack-bridge", "cloudflared"] as const; type ServiceName = (typeof SERVICE_NAMES)[number]; function startService( @@ -242,6 +242,7 @@ export function stopAll(opts: ServiceOptions = {}): void { const pidDir = resolvePidDir(opts); ensurePidDir(pidDir); stopService(pidDir, "cloudflared"); + stopService(pidDir, "slack-bridge"); stopService(pidDir, "telegram-bridge"); info("All services stopped."); } @@ -255,9 +256,12 @@ export async function startAll(opts: ServiceOptions = {}): Promise { if (!process.env.TELEGRAM_BOT_TOKEN) { warn("TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start."); warn("Create a bot via @BotFather on Telegram and set the token."); - } else if (!process.env.NVIDIA_API_KEY) { - warn("NVIDIA_API_KEY not set — Telegram bridge will not start."); - warn("Set NVIDIA_API_KEY if you want Telegram requests to reach inference."); + } + if (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_APP_TOKEN) { + warn("SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set — Slack bridge will not start."); + } + if (!process.env.TELEGRAM_BOT_TOKEN && (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_APP_TOKEN)) { + warn("No bot tokens configured — only cloudflared will start."); } // Warn if no sandbox is ready @@ -289,8 +293,8 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } } - // Telegram bridge (only if both token and API key are set) - if (process.env.TELEGRAM_BOT_TOKEN && process.env.NVIDIA_API_KEY) { + // Telegram bridge (only if token is set) + if (process.env.TELEGRAM_BOT_TOKEN) { const sandboxName = opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default"; startService( @@ -302,6 +306,19 @@ export async function startAll(opts: ServiceOptions = {}): Promise { ); } + // Slack bridge (only if both tokens are set) + if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { + const sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default"; + startService( + pidDir, + "slack-bridge", + "node", + [join(repoDir, "scripts", "slack-bridge.js")], + { SANDBOX_NAME: sandboxName }, + ); + } + // cloudflared tunnel try { execSync("command -v cloudflared", { @@ -359,6 +376,12 @@ export async function startAll(opts: ServiceOptions = {}): Promise { console.log(" │ Telegram: not started (no token) │"); } + if (isRunning(pidDir, "slack-bridge")) { + console.log(" │ Slack: bridge running │"); + } else { + console.log(" │ Slack: not started (missing tokens) │"); + } + console.log(" │ │"); console.log(" │ Run 'openshell term' to monitor egress approvals │"); console.log(" └─────────────────────────────────────────────────────┘"); diff --git a/test/cli.test.js b/test/cli.test.js index b71cde1c4..68f312981 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -106,6 +106,11 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, NVIDIA_API_KEY: "", + OPENAI_API_KEY: "", + ANTHROPIC_API_KEY: "", + GEMINI_API_KEY: "", + COMPATIBLE_API_KEY: "", + COMPATIBLE_ANTHROPIC_API_KEY: "", TELEGRAM_BOT_TOKEN: "", }); diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 828432633..f2a13045e 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -68,6 +68,7 @@ describe("credential exposure in process arguments", () => { expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("NVIDIA_API_KEY"/); expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("DISCORD_BOT_TOKEN"/); expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("SLACK_BOT_TOKEN"/); + expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("SLACK_APP_TOKEN"/); }); it("onboard.js curl probes use explicit timeouts", () => { diff --git a/test/service-env.test.js b/test/service-env.test.js index 429e17a70..138cb65d7 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -20,6 +20,11 @@ describe("service environment", () => { env: { ...process.env, NVIDIA_API_KEY: "", + OPENAI_API_KEY: "", + ANTHROPIC_API_KEY: "", + GEMINI_API_KEY: "", + COMPATIBLE_API_KEY: "", + COMPATIBLE_ANTHROPIC_API_KEY: "", TELEGRAM_BOT_TOKEN: "", SANDBOX_NAME: "test-box", TMPDIR: workspace, @@ -29,25 +34,7 @@ describe("service environment", () => { expect(result).not.toContain("NVIDIA_API_KEY required"); expect(result).toContain("TELEGRAM_BOT_TOKEN not set"); expect(result).toContain("Telegram: not started (no token)"); - }); - - it("warns and skips Telegram bridge when token is set without NVIDIA_API_KEY", () => { - const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-key-")); - const result = execFileSync("bash", [scriptPath], { - encoding: "utf-8", - env: { - ...process.env, - NVIDIA_API_KEY: "", - TELEGRAM_BOT_TOKEN: "test-token", - SANDBOX_NAME: "test-box", - TMPDIR: workspace, - }, - }); - - expect(result).not.toContain("NVIDIA_API_KEY required"); - expect(result).toContain("NVIDIA_API_KEY not set"); - expect(result).toContain("Telegram: not started (no token)"); - }); + }, 20000); }); describe("resolveOpenshell logic", () => {