diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 76e9512f5..e7d966069 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -733,7 +733,7 @@ function help() { nemoclaw deploy Deploy to a Brev VM and start services ${G}Services:${R} - nemoclaw start Start auxiliary services ${D}(Telegram, tunnel)${R} + nemoclaw start Start services ${D}(gateway, port fwd, Telegram, tunnel)${R} nemoclaw stop Stop all services nemoclaw status Show sandbox list and service status diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 303caf696..2aaf8a1ba 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -2,8 +2,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Start NemoClaw auxiliary services: Telegram bridge -# and cloudflared tunnel for public access. +# Start NemoClaw services: OpenClaw gateway (inside sandbox), +# port forwarding, Telegram bridge, and cloudflared tunnel. # # Usage: # TELEGRAM_BOT_TOKEN=... ./scripts/start-services.sh # start all @@ -56,6 +56,24 @@ fail() { exit 1 } +# Validate sandbox/gateway names to prevent shell injection +validate_name() { + case "$1" in + (*[!A-Za-z0-9._-]*|'') fail "Invalid identifier: '$1'" ;; + esac +} + +# Resolve sandbox name: explicit flag > auto-detect from openshell +resolve_sandbox() { + if [ "$SANDBOX_NAME" != "default" ]; then + printf '%s\n' "$SANDBOX_NAME" + return + fi + if command -v openshell > /dev/null 2>&1; then + openshell sandbox list 2>/dev/null | awk '/Ready/{print $1; exit}' || true + fi +} + is_running() { local pidfile="$PIDDIR/$1.pid" if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then @@ -97,7 +115,7 @@ stop_service() { show_status() { mkdir -p "$PIDDIR" echo "" - for svc in telegram-bridge cloudflared; do + for svc in openclaw-gateway gateway-forward telegram-bridge cloudflared; do if is_running "$svc"; then echo -e " ${GREEN}●${NC} $svc (PID $(cat "$PIDDIR/$svc.pid"))" else @@ -119,6 +137,8 @@ do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared stop_service telegram-bridge + stop_service gateway-forward + stop_service openclaw-gateway info "All services stopped." } @@ -141,6 +161,40 @@ do_start() { mkdir -p "$PIDDIR" + # ── OpenClaw gateway inside sandbox ────────────────────────────── + # Start the OpenClaw gateway inside the sandbox and forward + # port 18789 to the host so external dashboards (e.g. Mission + # Control) can reach the gateway after a reboot. + if command -v openshell > /dev/null 2>&1; then + local sandbox + sandbox="$(resolve_sandbox)" + if [ -n "$sandbox" ]; then + validate_name "$sandbox" + + # Start gateway inside sandbox (idempotent — skips if already running). + # Run `openclaw gateway run` in the foreground (inside sandbox) so the + # host-side nohup wrapper keeps a live PID for status tracking. + if ! is_running "openclaw-gateway"; then + info "Starting OpenClaw gateway inside sandbox '$sandbox'..." + start_service openclaw-gateway \ + openshell sandbox exec "$sandbox" -- openclaw gateway run + # Give the gateway a moment to bind its port + sleep 3 + fi + + # Forward dashboard port from sandbox to host (idempotent). + # Do NOT pass --background; start_service already runs under nohup &. + if ! is_running "gateway-forward"; then + info "Forwarding port $DASHBOARD_PORT from sandbox '$sandbox'..." + start_service gateway-forward \ + openshell forward start "$DASHBOARD_PORT" "$sandbox" + fi + else + warn "No sandbox found. Gateway and port forwarding skipped." + warn "Run 'nemoclaw onboard' first to create a sandbox." + fi + fi + # Telegram bridge (only if token provided) if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ @@ -183,6 +237,16 @@ do_start() { printf " │ Public URL: %-40s│\n" "$tunnel_url" fi + if is_running openclaw-gateway; then + printf " │ Gateway: %-40s│\n" "running (port $DASHBOARD_PORT)" + else + printf " │ Gateway: %-40s│\n" "not started" + fi + + if is_running gateway-forward; then + printf " │ Port fwd: %-40s│\n" "$DASHBOARD_PORT -> sandbox" + fi + if is_running telegram-bridge; then echo " │ Telegram: bridge running │" else diff --git a/test/cli.test.js b/test/cli.test.js index f255c6781..eb8a6f99e 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -35,6 +35,12 @@ describe("CLI dispatch", () => { expect(r.out.includes("Policy Presets")).toBeTruthy(); }); + it("help mentions gateway in start description", () => { + const r = run("help"); + assert.equal(r.code, 0); + assert.ok(r.out.includes("gateway"), "help should mention gateway in start description"); + }); + it("--help exits 0", () => { expect(run("--help").code).toBe(0); }); diff --git a/test/service-env.test.js b/test/service-env.test.js index 3d9e39c75..fa678ae33 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync, execFileSync } from "node:child_process"; +import { execSync, execFileSync, spawnSync } from "node:child_process"; import { writeFileSync, unlinkSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveOpenshell } from "../bin/lib/resolve-openshell"; +const START_SERVICES_SH = join(import.meta.dirname, "..", "scripts", "start-services.sh"); + describe("service environment", () => { describe("resolveOpenshell logic", () => { it("returns command -v result when absolute path", () => { @@ -330,4 +332,73 @@ describe("service environment", () => { } }); }); + + describe("validate_name", () => { + // Test the validate_name case pattern directly (sourcing the full script + // has side effects that interfere with the test environment). + function testValidateName(name) { + return spawnSync( + "bash", + ["-c", ` + validate_name() { + case "$1" in + (*[!A-Za-z0-9._-]*|'') return 1 ;; + esac + } + validate_name ${JSON.stringify(name)} + `], + { encoding: "utf-8" } + ); + } + + it("accepts valid sandbox names", () => { + expect(testValidateName("my-sandbox").status).toBe(0); + }); + + it("rejects names with shell metacharacters", () => { + expect(testValidateName("foo;rm -rf /").status).not.toBe(0); + }); + + it("rejects empty names", () => { + expect(testValidateName("").status).not.toBe(0); + }); + }); + + describe("resolve_sandbox", () => { + it("returns explicit sandbox name when not default", () => { + const result = spawnSync( + "bash", + ["-c", ` + SANDBOX_NAME="my-box" + resolve_sandbox() { + if [ "$SANDBOX_NAME" != "default" ]; then + printf '%s\\n' "$SANDBOX_NAME" + return + fi + } + resolve_sandbox + `], + { encoding: "utf-8" } + ); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("my-box"); + }); + }); + + describe("service list includes gateway services", () => { + it("show_status iterates openclaw-gateway and gateway-forward", () => { + const script = readFileSync(START_SERVICES_SH, "utf-8"); + expect(script).toContain("openclaw-gateway"); + expect(script).toContain("gateway-forward"); + expect(script).toMatch( + /for svc in openclaw-gateway gateway-forward telegram-bridge cloudflared/ + ); + }); + + it("do_stop stops gateway services", () => { + const script = readFileSync(START_SERVICES_SH, "utf-8"); + expect(script).toContain("stop_service gateway-forward"); + expect(script).toContain("stop_service openclaw-gateway"); + }); + }); });