diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 79629eec2b3..ee084a8fd14 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -198,6 +198,7 @@ Use the matching policy preset (`telegram`, `discord`, `slack`, or `whatsapp`) o ## Tunnel Command When the host has `cloudflared`, `nemoclaw tunnel start` starts a cloudflared tunnel that can expose the dashboard with a public URL. +Set `CLOUDFLARE_TUNNEL_TOKEN` before running the command when you want to use a Cloudflare named tunnel instead of a generated quick-tunnel URL. `nemoclaw tunnel stop` stops the tunnel and asks NemoClaw to stop the in-sandbox gateway for the selected or default sandbox. The older `nemoclaw start` still works as a deprecated alias. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d7808ac78a7..a281261d4ef 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -978,12 +978,24 @@ For a remote Brev instance, SSH to the instance and run `openshell term` there, ### `nemoclaw tunnel start` -Start optional host auxiliary services. This is the cloudflared tunnel when `cloudflared` is installed (for a public URL to the dashboard). Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `nemoclaw onboard` and runs through OpenShell-managed constructs. +Start optional host auxiliary services. +This is the cloudflared tunnel when `cloudflared` is installed, which exposes the dashboard with a public URL. +Channel messaging (Telegram, Discord, Slack) is not started here; it is configured during `nemoclaw onboard` and runs through OpenShell-managed constructs. ```console $ nemoclaw tunnel start ``` +By default, NemoClaw starts a Cloudflare quick tunnel and prints the generated `*.trycloudflare.com` URL when `cloudflared` reports it. +Set `CLOUDFLARE_TUNNEL_TOKEN` to start a Cloudflare named tunnel instead. +The named tunnel hostname and `localhost:` route must already be configured in the Cloudflare dashboard. +NemoClaw passes the token to `cloudflared` through the `TUNNEL_TOKEN` environment variable, so the token does not appear in the `cloudflared` command-line arguments. + +```console +$ export CLOUDFLARE_TUNNEL_TOKEN= +$ nemoclaw tunnel start +``` + `nemoclaw start` remains as a deprecated alias that prints a warning and delegates to `tunnel start`. ### `nemoclaw tunnel stop` diff --git a/src/lib/core/json-types.ts b/src/lib/core/json-types.ts index cb65ac84806..3f4934064f6 100644 --- a/src/lib/core/json-types.ts +++ b/src/lib/core/json-types.ts @@ -25,3 +25,11 @@ export type JsonValue = JsonScalar | JsonObject | JsonValue[]; /** A JSON-compatible object with string keys and recursive values. */ export type JsonObject = { [key: string]: JsonValue }; + +/** Generic object record used when parsed input has not been domain-validated. */ +export type UnknownRecord = Record; + +/** Return true when a value is a non-array object record. */ +export function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index 6bc45727613..42dec0b5118 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -10,7 +10,7 @@ import fs from "node:fs"; import path from "node:path"; - +import { isRecord, type UnknownRecord } from "../core/json-types"; import { buildPolicySetCommand } from "../policy"; import { run } from "../runner"; import { DEFAULT_AGENT_CONFIG, resolveAgentConfig } from "../sandbox/config"; @@ -18,8 +18,6 @@ import { resolveNemoclawStateDir } from "../state/paths"; import { appendAuditEntry, type ShieldsAuditEntry } from "./audit"; import { lockAgentConfig } from "./index"; -type UnknownRecord = { [key: string]: unknown }; - interface ShieldsStatePatch { shieldsDown?: boolean; shieldsDownAt?: string | null; @@ -43,10 +41,6 @@ interface TimerArgs { const STATE_DIR = resolveNemoclawStateDir(); -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseTimerArgs(argv: string[]): TimerArgs | null { const [sandboxName, snapshotPath, restoreAtIso, configPath, configDir, processToken] = argv; const restoreAtMs = restoreAtIso ? new Date(restoreAtIso).getTime() : Number.NaN; diff --git a/src/lib/skill-install.ts b/src/lib/skill-install.ts index 49c358e3ad4..f83141edd68 100644 --- a/src/lib/skill-install.ts +++ b/src/lib/skill-install.ts @@ -7,23 +7,21 @@ // OpenClaw). Non-OpenClaw agents get a "restart gateway" hint until a // generic refresh contract is defined in the manifest schema. +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; // yaml is a production dependency (used by policies.ts, onboard.ts) import YAML from "yaml"; +import { isRecord } from "./core/json-types"; + // ── Frontmatter parsing ────────────────────────────────────────── type FrontmatterScalar = string | number | boolean | null | undefined; type FrontmatterValue = FrontmatterScalar | FrontmatterRecord | FrontmatterValue[]; type FrontmatterRecord = { [key: string]: FrontmatterValue }; -function isRecord(value: FrontmatterValue): value is FrontmatterRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export interface SkillFrontmatter { name: string; [key: string]: FrontmatterValue; @@ -117,6 +115,7 @@ export function resolveSkillPaths( // Re-export shellQuote from runner.ts — a repo-wide test enforces // a single definition lives in runner.ts. const { shellQuote } = require("./runner"); + export { shellQuote }; const SAFE_PATH_RE = /^[A-Za-z0-9._\-/]+$/; diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index e996b4cd9cf..3d0188b47f7 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -30,6 +30,7 @@ import { resolveOpenshell } from "../adapters/openshell/resolve.js"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; +import { isRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; import * as registry from "./registry.js"; @@ -125,12 +126,6 @@ export interface SafeExtractResult { error?: string; } -type UnknownRecord = { [key: string]: unknown }; - -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index 0c96b6f3442..19587937c10 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -1,15 +1,16 @@ // 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 childProcess, { type SpawnSyncReturns } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. import { getServiceStatuses, + getTunnelUrl, readCloudflaredState, showStatus, startAll, @@ -28,6 +29,35 @@ const ollamaProxyDistPath = resolve( "proxy.js", ); +describe("getTunnelUrl", () => { + let pidDir: string; + + beforeEach(() => { + pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-url-test-")); + }); + + afterEach(() => { + rmSync(pidDir, { recursive: true, force: true }); + }); + + it("returns empty string when the cloudflared log does not exist", () => { + expect(getTunnelUrl(pidDir, 18789)).toBe(""); + }); + + it("parses quick tunnel URLs and strips fragments", () => { + writeFileSync(join(pidDir, "cloudflared.log"), "https://abc-def.trycloudflare.com/path#secret\n"); + expect(getTunnelUrl(pidDir, 18789)).toBe("https://abc-def.trycloudflare.com/path"); + }); + + it("parses the named tunnel hostname matching the dashboard port", () => { + writeFileSync( + join(pidDir, "cloudflared.log"), + '2026-01-01T00:00:00Z INF Updated config="{\\"ingress\\":[{\\"hostname\\":\\"other.example.com\\", \\"service\\":\\"http://localhost:9999\\"}, {\\"hostname\\":\\"agent.example.com\\", \\"service\\":\\"http://localhost:18789\\"}]}" version=1\n', + ); + expect(getTunnelUrl(pidDir, 18789)).toBe("https://agent.example.com"); + }); +}); + describe("getServiceStatuses", () => { let pidDir: string; @@ -174,15 +204,22 @@ describe("startAll", () => { let tmpDir: string; let pidDir: string; let originalPath: string | undefined; + let originalCloudflareTunnelToken: string | undefined; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-start-test-")); pidDir = join(tmpDir, "pids"); originalPath = process.env.PATH; + originalCloudflareTunnelToken = process.env.CLOUDFLARE_TUNNEL_TOKEN; }); afterEach(() => { process.env.PATH = originalPath; + if (originalCloudflareTunnelToken === undefined) { + delete process.env.CLOUDFLARE_TUNNEL_TOKEN; + } else { + process.env.CLOUDFLARE_TUNNEL_TOKEN = originalCloudflareTunnelToken; + } const pid = readCloudflaredState(pidDir); if (pid.kind === "running") { try { @@ -223,6 +260,36 @@ describe("startAll", () => { expect(output).not.toContain("evil.test"); expect(output).not.toContain("secret-fragment"); }); + + it("starts a named tunnel from CLOUDFLARE_TUNNEL_TOKEN without putting the token in argv", async () => { + const binDir = join(tmpDir, "bin"); + mkdirSync(binDir, { recursive: true }); + const fakeCloudflared = join(binDir, "cloudflared"); + writeFileSync( + fakeCloudflared, + [ + "#!/usr/bin/env sh", + "printf 'argv:%s\\n' \"$*\"", + "if [ \"${TUNNEL_TOKEN:-}\" = 'named-secret' ]; then echo token-env-present; fi", + "echo 'config=\"{\\\"ingress\\\":[{\\\"hostname\\\":\\\"agent.example.com\\\", \\\"service\\\":\\\"http://localhost:12345\\\"}]}\"'", + "sleep 20", + ].join("\n"), + ); + chmodSync(fakeCloudflared, 0o700); + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + process.env.CLOUDFLARE_TUNNEL_TOKEN = "named-secret"; + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await startAll({ pidDir, dashboardPort: 12345 }); + + const log = readFileSync(join(pidDir, "cloudflared.log"), "utf-8"); + const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(log).toContain("argv:tunnel run"); + expect(log).toContain("token-env-present"); + expect(log).not.toContain("named-secret"); + expect(output).toContain("https://agent.example.com"); + }); }); // #2604: readCloudflaredState is the shared source of truth used by both diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index 8b2d2fc989a..dbee2081c98 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -11,16 +11,16 @@ import { mkdirSync, openSync, readFileSync, - writeFileSync, unlinkSync, + writeFileSync, } from "node:fs"; import { basename, join } from "node:path"; - -import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; -import { renderBox } from "../cli/banner"; import { dockerSpawnSync } from "../adapters/docker"; -import { DASHBOARD_PORT } from "../core/ports"; import { resolveOpenshell } from "../adapters/openshell/resolve"; +import { renderBox } from "../cli/banner"; +import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; +import { isRecord } from "../core/json-types"; +import { DASHBOARD_PORT } from "../core/ports"; import { buildSubprocessEnv } from "../subprocess-env"; // --------------------------------------------------------------------------- @@ -36,6 +36,8 @@ export interface ServiceOptions { repoDir?: string; /** Override PID directory (default: /tmp/nemoclaw-services-{sandbox}). */ pidDir?: string; + /** Cloudflare named tunnel token. Falls back to CLOUDFLARE_TUNNEL_TOKEN. */ + cloudflareTunnelToken?: string; } export interface ServiceStatus { @@ -149,6 +151,84 @@ function extractTryCloudflareUrl(log: string): string | null { return null; } +function formatNamedTunnelUrl(hostname: string): string | null { + const normalized = hostname.trim().replace(/\.$/, "").toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(normalized)) { + return null; + } + return `https://${normalized}`; +} + +function serviceTargetsDashboard(service: string, dashboardPort: number): boolean { + try { + const url = new URL(service); + return ( + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1") && + url.port === String(dashboardPort) + ); + } catch { + return service === `http://localhost:${String(dashboardPort)}`; + } +} + +function getConfigIngressEntries(config: unknown): Array<{ hostname: string; service: string }> { + if (!isRecord(config) || !Array.isArray(config.ingress)) return []; + + const entries: Array<{ hostname: string; service: string }> = []; + for (const entry of config.ingress) { + if (!isRecord(entry)) continue; + const { hostname, service } = entry; + if (typeof hostname === "string" && typeof service === "string") { + entries.push({ hostname, service }); + } + } + return entries; +} + +function extractNamedCloudflareUrl(log: string, dashboardPort: number): string | null { + for (const match of log.matchAll(/config="((?:\\"|[^"])*)"/g)) { + const escapedConfig = match[1]; + if (!escapedConfig) continue; + try { + const configText = JSON.parse(`"${escapedConfig}"`) as string; + const entries = getConfigIngressEntries(JSON.parse(configText) as unknown); + for (const entry of entries) { + if (!serviceTargetsDashboard(entry.service, dashboardPort)) continue; + const url = formatNamedTunnelUrl(entry.hostname); + if (url) return url; + } + } catch { + // Fall through to the regex parser below for partial or unusual log lines. + } + } + + const port = String(dashboardPort).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const servicePattern = new RegExp(`\\\\"service\\\\"\\s*:\\s*\\\\"http://localhost:${port}/?\\\\"`, "g"); + for (const line of log.split(/\r?\n/)) { + for (const serviceMatch of line.matchAll(servicePattern)) { + const prefix = line.slice(0, serviceMatch.index ?? 0); + let hostname: string | null = null; + for (const hostnameMatch of prefix.matchAll(/\\"hostname\\"\s*:\s*\\"([^"\\]+)\\"/g)) { + hostname = hostnameMatch[1] ?? null; + } + if (!hostname) continue; + const url = formatNamedTunnelUrl(hostname); + if (url) return url; + } + } + + return null; +} + +/** Extract the active cloudflared public URL from a service log. */ +export function getTunnelUrl(pidDir: string, dashboardPort: number): string { + const logFile = join(pidDir, "cloudflared.log"); + if (!existsSync(logFile)) return ""; + const log = readFileSync(logFile, "utf-8"); + return extractNamedCloudflareUrl(log, dashboardPort) ?? extractTryCloudflareUrl(log) ?? ""; +} + export function readCloudflaredState(pidDir: string): CloudflaredState { const pidFile = join(pidDir, "cloudflared.pid"); if (!existsSync(pidFile)) return { kind: "stopped" }; @@ -343,8 +423,7 @@ export function showStatus(opts: ServiceOptions = {}): void { // Only show tunnel URL if cloudflared is actually running const logFile = join(pidDir, "cloudflared.log"); if (state.kind === "running" && existsSync(logFile)) { - const log = readFileSync(logFile, "utf-8"); - const publicUrl = extractTryCloudflareUrl(log); + const publicUrl = getTunnelUrl(pidDir, opts.dashboardPort ?? DASHBOARD_PORT); if (publicUrl) { info(`Public URL: ${publicUrl}`); } @@ -555,15 +634,22 @@ export async function startAll(opts: ServiceOptions = {}): Promise { // No host-side bridge processes are needed. See: PR #1081. // cloudflared tunnel + const tunnelToken = (opts.cloudflareTunnelToken ?? process.env.CLOUDFLARE_TUNNEL_TOKEN ?? "").trim(); try { execSync("command -v cloudflared", { stdio: ["ignore", "ignore", "ignore"], }); - startService(pidDir, "cloudflared", "cloudflared", [ - "tunnel", - "--url", - `http://localhost:${String(dashboardPort)}`, - ]); + if (tunnelToken) { + startService(pidDir, "cloudflared", "cloudflared", ["tunnel", "run"], { + TUNNEL_TOKEN: tunnelToken, + }); + } else { + startService(pidDir, "cloudflared", "cloudflared", [ + "tunnel", + "--url", + `http://localhost:${String(dashboardPort)}`, + ]); + } } catch { warn("cloudflared not found — no public URL. Install cloudflared manually if you need one."); } @@ -571,13 +657,9 @@ export async function startAll(opts: ServiceOptions = {}): Promise { // 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 (extractTryCloudflareUrl(log)) { - break; - } + if (getTunnelUrl(pidDir, dashboardPort)) { + break; } await new Promise((resolve) => { setTimeout(resolve, 1000); @@ -586,10 +668,8 @@ export async function startAll(opts: ServiceOptions = {}): Promise { } let tunnelUrl = ""; - const cfLogFile = join(pidDir, "cloudflared.log"); - if (isRunning(pidDir, "cloudflared") && existsSync(cfLogFile)) { - const log = readFileSync(cfLogFile, "utf-8"); - tunnelUrl = extractTryCloudflareUrl(log) ?? ""; + if (isRunning(pidDir, "cloudflared")) { + tunnelUrl = getTunnelUrl(pidDir, dashboardPort); } const bannerLines = [