Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 13 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:<dashboard-port>` 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=<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`
Expand Down
8 changes: 8 additions & 0 deletions src/lib/core/json-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

/** 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);
}
8 changes: 1 addition & 7 deletions src/lib/shields/timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@

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";
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;
Expand All @@ -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;
Expand Down
9 changes: 4 additions & 5 deletions src/lib/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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._\-/]+$/;
Expand Down
7 changes: 1 addition & 6 deletions src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
71 changes: 69 additions & 2 deletions src/lib/tunnel/services.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading