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
2 changes: 1 addition & 1 deletion bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ function help() {
nemoclaw deploy <instance> 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

Expand Down
70 changes: 67 additions & 3 deletions scripts/start-services.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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."
}

Expand All @@ -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 \
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
73 changes: 72 additions & 1 deletion test/service-env.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
});