diff --git a/bin/lib/services.js b/bin/lib/services.js new file mode 100644 index 000000000..3defe2e40 --- /dev/null +++ b/bin/lib/services.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin CJS shim — implementation lives in src/lib/services.ts +module.exports = require("../../dist/lib/services"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 0d20c5e41..6d4d06a60 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -782,15 +782,19 @@ async function deploy(instanceName) { } async function start() { + const { startAll } = require("./lib/services"); 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"`); + await startAll({ sandboxName: safeName || undefined }); } function stop() { - run(`bash "${SCRIPTS}/start-services.sh" --stop`); + const { stopAll } = require("./lib/services"); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = + defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + stopAll({ sandboxName: safeName || undefined }); } function debug(args) { @@ -865,7 +869,8 @@ function showStatus() { } // Show service status - run(`bash "${SCRIPTS}/start-services.sh" --status`); + const { showStatus: showServiceStatus } = require("./lib/services"); + showServiceStatus({ sandboxName: defaultSandbox || undefined }); } async function listSandboxes() { diff --git a/src/lib/services.test.ts b/src/lib/services.test.ts new file mode 100644 index 000000000..702438e48 --- /dev/null +++ b/src/lib/services.test.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Import from compiled dist/ so coverage is attributed correctly. +import { + getServiceStatuses, + showStatus, + stopAll, +} from "../../dist/lib/services"; + +describe("getServiceStatuses", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("returns stopped status when no PID files exist", () => { + const statuses = getServiceStatuses({ pidDir }); + expect(statuses).toHaveLength(2); + for (const s of statuses) { + expect(s.running).toBe(false); + expect(s.pid).toBeNull(); + } + }); + + it("returns service names telegram-bridge and cloudflared", () => { + const statuses = getServiceStatuses({ pidDir }); + const names = statuses.map((s) => s.name); + expect(names).toContain("telegram-bridge"); + expect(names).toContain("cloudflared"); + }); + + it("detects a stale PID file as not running with null pid", () => { + // Write a PID that doesn't correspond to a running process + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + const statuses = getServiceStatuses({ pidDir }); + const cf = statuses.find((s) => s.name === "cloudflared"); + expect(cf?.running).toBe(false); + // Dead processes should have pid normalized to null + expect(cf?.pid).toBeNull(); + }); + + it("ignores invalid PID file contents", () => { + writeFileSync(join(pidDir, "telegram-bridge.pid"), "not-a-number"); + const statuses = getServiceStatuses({ pidDir }); + const tg = statuses.find((s) => s.name === "telegram-bridge"); + expect(tg?.pid).toBeNull(); + expect(tg?.running).toBe(false); + }); + + it("creates pidDir if it does not exist", () => { + const nested = join(pidDir, "nested", "deep"); + const statuses = getServiceStatuses({ pidDir: nested }); + expect(existsSync(nested)).toBe(true); + expect(statuses).toHaveLength(2); + }); +}); + +describe("sandbox name validation", () => { + it("rejects names with path traversal", () => { + expect(() => getServiceStatuses({ sandboxName: "../escape" })).toThrow("Invalid sandbox name"); + }); + + it("rejects names with slashes", () => { + expect(() => getServiceStatuses({ sandboxName: "foo/bar" })).toThrow("Invalid sandbox name"); + }); + + it("rejects empty names", () => { + expect(() => getServiceStatuses({ sandboxName: "" })).toThrow("Invalid sandbox name"); + }); + + it("accepts valid alphanumeric names", () => { + expect(() => getServiceStatuses({ sandboxName: "my-sandbox.1" })).not.toThrow(); + }); +}); + +describe("showStatus", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("prints stopped status for all services", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showStatus({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("telegram-bridge"); + expect(output).toContain("cloudflared"); + expect(output).toContain("stopped"); + logSpy.mockRestore(); + }); + + it("does not show tunnel URL when cloudflared is not running", () => { + // Write a stale log file but no running process + writeFileSync( + join(pidDir, "cloudflared.log"), + "https://abc-def.trycloudflare.com", + ); + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + showStatus({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + // Should NOT show the URL since cloudflared is not actually running + expect(output).not.toContain("Public URL"); + logSpy.mockRestore(); + }); +}); + +describe("stopAll", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("removes stale PID files", () => { + writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); + writeFileSync(join(pidDir, "telegram-bridge.pid"), "999999998"); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + logSpy.mockRestore(); + + expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); + expect(existsSync(join(pidDir, "telegram-bridge.pid"))).toBe(false); + }); + + it("is idempotent — calling twice does not throw", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + stopAll({ pidDir }); + logSpy.mockRestore(); + }); + + it("logs stop messages", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + stopAll({ pidDir }); + const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("All services stopped"); + logSpy.mockRestore(); + }); +}); diff --git a/src/lib/services.ts b/src/lib/services.ts new file mode 100644 index 000000000..9582a5921 --- /dev/null +++ b/src/lib/services.ts @@ -0,0 +1,383 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, execSync, spawn } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, + unlinkSync, +} from "node:fs"; +import { join } from "node:path"; +import { platform } from "node:os"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ServiceOptions { + /** Sandbox name — must match the name used by start/stop/status. */ + sandboxName?: string; + /** Dashboard port for cloudflared (default: 18789). */ + dashboardPort?: number; + /** Repo root directory — used to locate scripts/. */ + repoDir?: string; + /** Override PID directory (default: /tmp/nemoclaw-services-{sandbox}). */ + pidDir?: string; +} + +export interface ServiceStatus { + name: string; + running: boolean; + pid: number | null; +} + +// --------------------------------------------------------------------------- +// Colour helpers — respect NO_COLOR +// --------------------------------------------------------------------------- + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const GREEN = useColor ? "\x1b[0;32m" : ""; +const RED = useColor ? "\x1b[0;31m" : ""; +const YELLOW = useColor ? "\x1b[1;33m" : ""; +const NC = useColor ? "\x1b[0m" : ""; + +function info(msg: string): void { + console.log(`${GREEN}[services]${NC} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${YELLOW}[services]${NC} ${msg}`); +} + +// --------------------------------------------------------------------------- +// PID helpers +// --------------------------------------------------------------------------- + +function ensurePidDir(pidDir: string): void { + if (!existsSync(pidDir)) { + mkdirSync(pidDir, { recursive: true }); + } +} + +function readPid(pidDir: string, name: string): number | null { + const pidFile = join(pidDir, `${name}.pid`); + if (!existsSync(pidFile)) return null; + const raw = readFileSync(pidFile, "utf-8").trim(); + const pid = Number(raw); + return Number.isFinite(pid) && pid > 0 ? pid : null; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isRunning(pidDir: string, name: string): boolean { + const pid = readPid(pidDir, name); + if (pid === null) return false; + return isAlive(pid); +} + +function writePid(pidDir: string, name: string, pid: number): void { + writeFileSync(join(pidDir, `${name}.pid`), String(pid)); +} + +function removePid(pidDir: string, name: string): void { + const pidFile = join(pidDir, `${name}.pid`); + if (existsSync(pidFile)) { + unlinkSync(pidFile); + } +} + +// --------------------------------------------------------------------------- +// Service lifecycle +// --------------------------------------------------------------------------- + +const SERVICE_NAMES = ["telegram-bridge", "cloudflared"] as const; +type ServiceName = (typeof SERVICE_NAMES)[number]; + +function startService( + pidDir: string, + name: ServiceName, + command: string, + args: string[], + env?: Record, +): void { + if (isRunning(pidDir, name)) { + const pid = readPid(pidDir, name); + info(`${name} already running (PID ${String(pid)})`); + return; + } + + // Open a single fd for the log file — mirrors bash `>log 2>&1`. + // Uses child_process.spawn directly because execa's typed API + // does not accept raw file descriptors for stdio. + const logFile = join(pidDir, `${name}.log`); + const logFd = openSync(logFile, "w"); + const subprocess = spawn(command, args, { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, ...env }, + }); + closeSync(logFd); + + // Swallow errors on the detached child (e.g. ENOENT if the command + // doesn't exist) so Node doesn't crash with an unhandled 'error' event. + subprocess.on("error", () => {}); + + const pid = subprocess.pid; + if (pid === undefined) { + warn(`${name} failed to start`); + return; + } + + subprocess.unref(); + writePid(pidDir, name, pid); + info(`${name} started (PID ${String(pid)})`); +} + +/** Poll for process exit after SIGTERM, escalate to SIGKILL if needed. */ +function stopService(pidDir: string, name: ServiceName): void { + const pid = readPid(pidDir, name); + if (pid === null) { + info(`${name} was not running`); + return; + } + + if (!isAlive(pid)) { + info(`${name} was not running`); + removePid(pidDir, name); + return; + } + + // Send SIGTERM + try { + process.kill(pid, "SIGTERM"); + } catch { + // Already dead between the check and the signal + removePid(pidDir, name); + info(`${name} stopped (PID ${String(pid)})`); + return; + } + + // Poll for exit (up to 3 seconds) + const deadline = Date.now() + 3000; + while (Date.now() < deadline && isAlive(pid)) { + // Busy-wait in 100ms increments (synchronous — matches stop being sync) + const start = Date.now(); + while (Date.now() - start < 100) { + /* spin */ + } + } + + // Escalate to SIGKILL if still alive + if (isAlive(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* already dead */ + } + } + + removePid(pidDir, name); + info(`${name} stopped (PID ${String(pid)})`); +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +/** Reject sandbox names that could escape the PID directory via path traversal. */ +const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function validateSandboxName(name: string): string { + if (!SAFE_NAME_RE.test(name) || name.includes("..")) { + throw new Error(`Invalid sandbox name: ${JSON.stringify(name)}`); + } + return name; +} + +function resolvePidDir(opts: ServiceOptions): string { + const sandbox = validateSandboxName( + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default", + ); + return opts.pidDir ?? `/tmp/nemoclaw-services-${sandbox}`; +} + +export function showStatus(opts: ServiceOptions = {}): void { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + + console.log(""); + for (const svc of SERVICE_NAMES) { + if (isRunning(pidDir, svc)) { + const pid = readPid(pidDir, svc); + console.log(` ${GREEN}●${NC} ${svc} (PID ${String(pid)})`); + } else { + console.log(` ${RED}●${NC} ${svc} (stopped)`); + } + } + console.log(""); + + // Only show tunnel URL if cloudflared is actually running + const logFile = join(pidDir, "cloudflared.log"); + if (isRunning(pidDir, "cloudflared") && existsSync(logFile)) { + const log = readFileSync(logFile, "utf-8"); + const match = /https:\/\/[a-z0-9-]*\.trycloudflare\.com/.exec(log); + if (match) { + info(`Public URL: ${match[0]}`); + } + } +} + +export function stopAll(opts: ServiceOptions = {}): void { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + stopService(pidDir, "cloudflared"); + stopService(pidDir, "telegram-bridge"); + info("All services stopped."); +} + +export async function startAll(opts: ServiceOptions = {}): Promise { + const pidDir = resolvePidDir(opts); + const dashboardPort = opts.dashboardPort ?? (Number(process.env.DASHBOARD_PORT) || 18789); + // Compiled location: dist/lib/services.js → repo root is 2 levels up + const repoDir = opts.repoDir ?? join(__dirname, "..", ".."); + + 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."); + } + + // Warn if no sandbox is ready + try { + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (!output.includes("Ready")) { + warn("No sandbox in Ready state. Telegram bridge may not work until sandbox is running."); + } + } catch { + /* openshell not installed or no ready sandbox — skip check */ + } + + ensurePidDir(pidDir); + + // WSL2 ships with broken IPv6 routing — force IPv4-first DNS for bridge processes + if (platform() === "linux") { + const isWSL = + !!process.env.WSL_DISTRO_NAME || + !!process.env.WSL_INTEROP || + (existsSync("/proc/version") && + readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft")); + if (isWSL) { + const existing = process.env.NODE_OPTIONS ?? ""; + process.env.NODE_OPTIONS = `${existing ? existing + " " : ""}--dns-result-order=ipv4first`; + info("WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes"); + } + } + + // Telegram bridge (only if both token and API key are set) + if (process.env.TELEGRAM_BOT_TOKEN && process.env.NVIDIA_API_KEY) { + const sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default"; + startService( + pidDir, + "telegram-bridge", + "node", + [join(repoDir, "scripts", "telegram-bridge.js")], + { SANDBOX_NAME: sandboxName }, + ); + } + + // cloudflared tunnel + try { + execSync("command -v cloudflared", { + stdio: ["ignore", "ignore", "ignore"], + }); + startService(pidDir, "cloudflared", "cloudflared", [ + "tunnel", + "--url", + `http://localhost:${String(dashboardPort)}`, + ]); + } catch { + warn("cloudflared not found — no public URL. Install: brev-setup.sh or manually."); + } + + // Wait for cloudflared URL + if (isRunning(pidDir, "cloudflared")) { + info("Waiting for tunnel URL..."); + const logFile = join(pidDir, "cloudflared.log"); + for (let i = 0; i < 15; i++) { + if (existsSync(logFile)) { + const log = readFileSync(logFile, "utf-8"); + if (/https:\/\/[a-z0-9-]*\.trycloudflare\.com/.test(log)) { + break; + } + } + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + } + + // Banner + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────┐"); + console.log(" │ NemoClaw Services │"); + console.log(" │ │"); + + let tunnelUrl = ""; + const cfLogFile = join(pidDir, "cloudflared.log"); + if (isRunning(pidDir, "cloudflared") && existsSync(cfLogFile)) { + const log = readFileSync(cfLogFile, "utf-8"); + const match = /https:\/\/[a-z0-9-]*\.trycloudflare\.com/.exec(log); + if (match) { + tunnelUrl = match[0]; + } + } + + if (tunnelUrl) { + console.log(` │ Public URL: ${tunnelUrl.padEnd(40)}│`); + } + + if (isRunning(pidDir, "telegram-bridge")) { + console.log(" │ Telegram: bridge running │"); + } else { + console.log(" │ Telegram: not started (no token) │"); + } + + console.log(" │ │"); + console.log(" │ Run 'openshell term' to monitor egress approvals │"); + console.log(" └─────────────────────────────────────────────────────┘"); + console.log(""); +} + +// --------------------------------------------------------------------------- +// Exported status helper (useful for programmatic access) +// --------------------------------------------------------------------------- + +export function getServiceStatuses(opts: ServiceOptions = {}): ServiceStatus[] { + const pidDir = resolvePidDir(opts); + ensurePidDir(pidDir); + return SERVICE_NAMES.map((name) => { + const running = isRunning(pidDir, name); + return { + name, + running, + pid: running ? readPid(pidDir, name) : null, + }; + }); +} diff --git a/test/cli.test.js b/test/cli.test.js index c3cf39118..b71cde1c4 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -111,7 +111,8 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).not.toContain("NVIDIA API Key required"); - expect(fs.readFileSync(markerFile, "utf8")).toContain("start-services.sh"); + // Services module now runs in-process (no bash shelling) + expect(r.out).toContain("NemoClaw Services"); }); it("unknown onboard option exits 1", () => {