openclaw-cloudflare 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3
- "changelog": ["@changesets/changelog-github", { "repo": "G4brym/openclaw-plugin-cloudflare" }],
3
+ "changelog": ["@changesets/changelog-github", { "repo": "G4brym/openclaw-cloudflare" }],
4
4
  "commit": false,
5
5
  "fixed": [],
6
6
  "linked": [],
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # openclaw-cloudflare
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#4](https://github.com/G4brym/openclaw-cloudflare/pull/4) [`a363c11`](https://github.com/G4brym/openclaw-cloudflare/commit/a363c119aa808284762306a529e0572496735684) Thanks [@G4brym](https://github.com/G4brym)! - Add MIT LICENSE file
8
+
9
+ ## 0.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#1](https://github.com/G4brym/openclaw-cloudflare/pull/1) [`cea0045`](https://github.com/G4brym/openclaw-cloudflare/commit/cea00459c619b0f75b51bc21d31f4f428c970cd3) Thanks [@G4brym](https://github.com/G4brym)! - Auto-install cloudflared binary when not found in managed mode
14
+
15
+ ### Patch Changes
16
+
17
+ - [#2](https://github.com/G4brym/openclaw-cloudflare/pull/2) [`f395045`](https://github.com/G4brym/openclaw-cloudflare/commit/f395045742bb31c55c0d4d953f8c79f5fb7ac478) Thanks [@G4brym](https://github.com/G4brym)! - Update GitHub repository references to match renamed repo
package/CLAUDE.md ADDED
@@ -0,0 +1,81 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ OpenClaw plugin that integrates Cloudflare Tunnel and Cloudflare Access. It spawns/manages a `cloudflared` process for tunnel mode and verifies Cloudflare Access JWTs to identify users. Published to npm as `openclaw-cloudflare`.
8
+
9
+ ## Commands
10
+
11
+ - **Run all tests:** `npm test` (runs `vitest run`)
12
+ - **Run a single test file:** `npx vitest run src/tunnel/access.test.ts`
13
+ - **Run tests matching a name:** `npx vitest run -t "pattern"`
14
+ - **Typecheck:** `npm run typecheck` (runs `tsc --noEmit`)
15
+
16
+ ## Architecture
17
+
18
+ ES module TypeScript project (`"type": "module"`) with no build/bundle step for development — the entry point is `./src/index.ts` directly.
19
+
20
+ ### Module Layers
21
+
22
+ ```
23
+ src/index.ts Plugin interface — exports {id, name, register()}
24
+ register() receives OpenClaw API (logger, registerService, registerHttpHandler)
25
+ Validates config, wires service + HTTP handler
26
+
27
+ src/tunnel/exposure.ts Orchestration — routes to correct mode (off / managed / access-only)
28
+ Returns a stop function or null
29
+
30
+ src/tunnel/cloudflared.ts Process management — finds/installs binary, spawns `cloudflared tunnel run`
31
+ Binary auto-install downloads from GitHub to ~/.openclaw/bin/
32
+ Passes token via TUNNEL_TOKEN env var (not CLI args)
33
+ Waits for connector registration on stderr, with timeout
34
+
35
+ src/tunnel/access.ts JWT verification — JWKS fetching with 10min cache, RS256/ES256
36
+ Uses Node.js WebCrypto (no external crypto deps)
37
+ Returns {email} or null on failure (never throws)
38
+ ```
39
+
40
+ ### Plugin Registration Flow
41
+
42
+ 1. `register(api)` validates config and exits early if mode is `"off"`
43
+ 2. Registers a **service** (`cloudflare-tunnel`) that on `start()`:
44
+ - Creates a JWT verifier if `teamDomain` is configured
45
+ - Calls `startGatewayCloudflareExposure()` which spawns cloudflared in managed mode
46
+ 3. Registers an **HTTP handler** that on every request:
47
+ - Strips `x-openclaw-user-email` and `x-openclaw-auth-source` headers (anti-spoofing)
48
+ - If verifier exists, reads `Cf-Access-Jwt-Assertion` header, verifies JWT, sets identity headers
49
+
50
+ ### Key Design Patterns
51
+
52
+ - **Dependency injection everywhere** — logger, exec functions, fetch are all injected, making every module fully testable with mocks
53
+ - **Graceful degradation** — functions return null instead of throwing; errors are logged and the plugin continues
54
+ - **Anti-spoofing** — identity headers are always stripped before verification, then re-set only after successful JWT validation
55
+
56
+ ## Configuration
57
+
58
+ Three modes configured via `tunnel.mode`:
59
+ - `"off"` (default) — plugin does nothing
60
+ - `"managed"` — spawns cloudflared, requires `tunnelToken` (config or `OPENCLAW_CLOUDFLARE_TUNNEL_TOKEN` env var)
61
+ - `"access-only"` — JWT verification only, expects external cloudflared
62
+
63
+ Config schema is defined in `openclaw.plugin.json`.
64
+
65
+ ## Workflow
66
+
67
+ The `main` branch is protected. All code changes must go through a pull request — never commit directly to main.
68
+
69
+ ## Versioning and Releases
70
+
71
+ Uses [changesets](https://github.com/changesets/changesets). PRs to main require a changeset file (enforced by CI). Merging a changeset to main triggers automated npm publish via GitHub Actions with OIDC provenance.
72
+
73
+ **Every PR to main must include a changeset.** Add one with `npx changeset` or manually create a file in `.changeset/` with this format:
74
+
75
+ ```md
76
+ ---
77
+ "openclaw-cloudflare": patch # or minor/major
78
+ ---
79
+
80
+ Description of the change
81
+ ```
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Gabriel Massadas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -42,9 +42,10 @@ Cloudflare integration is disabled.
42
42
  OpenClaw spawns and manages a `cloudflared` tunnel process automatically.
43
43
 
44
44
  **Requirements:**
45
- - `cloudflared` binary installed and in PATH (or at a known location)
46
45
  - A pre-configured tunnel token from the Cloudflare Zero Trust dashboard
47
46
 
47
+ > **Auto-install:** If `cloudflared` is not found in PATH or known locations, the plugin automatically downloads the latest release from GitHub to `~/.openclaw/bin/cloudflared`. No manual installation required.
48
+
48
49
  **Setup:**
49
50
 
50
51
  1. In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/), create a tunnel under **Networks > Tunnels**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-cloudflare",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Cloudflare integration plugin for OpenClaw (Tunnel, Access, and more)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,7 +24,9 @@
24
24
  "vitest": "^3.0.0"
25
25
  },
26
26
  "openclaw": {
27
- "extensions": ["./src/index.ts"],
27
+ "extensions": [
28
+ "./src/index.ts"
29
+ ],
28
30
  "install": {
29
31
  "npmSpec": "openclaw-cloudflare",
30
32
  "defaultChoice": "npm"
@@ -40,7 +42,7 @@
40
42
  ],
41
43
  "repository": {
42
44
  "type": "git",
43
- "url": "git+https://github.com/G4brym/openclaw-plugin-cloudflare.git"
45
+ "url": "git+https://github.com/G4brym/openclaw-cloudflare.git"
44
46
  },
45
47
  "publishConfig": {
46
48
  "provenance": true,
@@ -1,11 +1,12 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
  import type { Readable } from "node:stream";
3
- import { describe, expect, it, vi, beforeEach } from "vitest";
3
+ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
4
4
 
5
- // Mock spawn
5
+ // Mock spawn and execFile (promisified via util.promisify)
6
6
  const spawnMock = vi.fn();
7
+ const execFileMock = vi.fn();
7
8
  vi.mock("node:child_process", () => ({
8
- execFile: vi.fn(),
9
+ execFile: (...args: unknown[]) => execFileMock(...args),
9
10
  spawn: (...args: unknown[]) => spawnMock(...args),
10
11
  }));
11
12
 
@@ -15,6 +16,25 @@ vi.mock("node:fs", () => ({
15
16
  existsSync: (p: string) => existsSyncMock(p),
16
17
  }));
17
18
 
19
+ // Mock fs/promises
20
+ const mkdirMock = vi.fn().mockResolvedValue(undefined);
21
+ const writeFileMock = vi.fn().mockResolvedValue(undefined);
22
+ const chmodMock = vi.fn().mockResolvedValue(undefined);
23
+ const unlinkMock = vi.fn().mockResolvedValue(undefined);
24
+ vi.mock("node:fs/promises", () => ({
25
+ mkdir: (...args: unknown[]) => mkdirMock(...args),
26
+ writeFile: (...args: unknown[]) => writeFileMock(...args),
27
+ chmod: (...args: unknown[]) => chmodMock(...args),
28
+ unlink: (...args: unknown[]) => unlinkMock(...args),
29
+ }));
30
+
31
+ // Mock os.homedir
32
+ const homedirMock = vi.fn(() => "/home/testuser");
33
+ vi.mock("node:os", () => ({
34
+ default: { homedir: () => homedirMock() },
35
+ homedir: () => homedirMock(),
36
+ }));
37
+
18
38
  // Shared exec mock for findCloudflaredBinary
19
39
  const execMock = vi.fn();
20
40
 
@@ -70,6 +90,134 @@ describe("findCloudflaredBinary", () => {
70
90
  const result = await findCloudflaredBinary(execMock);
71
91
  expect(result).toBeNull();
72
92
  });
93
+
94
+ it("finds cloudflared in ~/.openclaw/bin", async () => {
95
+ const openclawBinPath = "/home/testuser/.openclaw/bin/cloudflared";
96
+ execMock.mockImplementation((cmd: string, _args: string[]) => {
97
+ if (cmd === "which") {
98
+ return Promise.reject(new Error("not found"));
99
+ }
100
+ return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
101
+ });
102
+ existsSyncMock.mockImplementation((p: string) => p === openclawBinPath);
103
+
104
+ const { findCloudflaredBinary } = await import("./cloudflared.js");
105
+ const result = await findCloudflaredBinary(execMock);
106
+ expect(result).toBe(openclawBinPath);
107
+ });
108
+ });
109
+
110
+ describe("installCloudflared", () => {
111
+ const originalPlatform = process.platform;
112
+ const originalArch = process.arch;
113
+
114
+ beforeEach(() => {
115
+ vi.resetModules();
116
+ vi.restoreAllMocks();
117
+ mkdirMock.mockResolvedValue(undefined);
118
+ writeFileMock.mockResolvedValue(undefined);
119
+ chmodMock.mockResolvedValue(undefined);
120
+ unlinkMock.mockResolvedValue(undefined);
121
+ homedirMock.mockReturnValue("/home/testuser");
122
+ });
123
+
124
+ afterEach(() => {
125
+ Object.defineProperty(process, "platform", { value: originalPlatform });
126
+ Object.defineProperty(process, "arch", { value: originalArch });
127
+ });
128
+
129
+ it("downloads and installs linux binary", async () => {
130
+ Object.defineProperty(process, "platform", { value: "linux" });
131
+ Object.defineProperty(process, "arch", { value: "x64" });
132
+
133
+ const binaryData = new Uint8Array([1, 2, 3]);
134
+ const fetchMock = vi.fn().mockResolvedValue({
135
+ ok: true,
136
+ arrayBuffer: () => Promise.resolve(binaryData.buffer),
137
+ });
138
+ vi.stubGlobal("fetch", fetchMock);
139
+
140
+ const { installCloudflared } = await import("./cloudflared.js");
141
+ const logger = { info: vi.fn() };
142
+ const result = await installCloudflared(logger);
143
+
144
+ expect(result).toBe("/home/testuser/.openclaw/bin/cloudflared");
145
+ expect(fetchMock).toHaveBeenCalledWith(
146
+ "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
147
+ { redirect: "follow" },
148
+ );
149
+ expect(mkdirMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin", { recursive: true });
150
+ expect(writeFileMock).toHaveBeenCalledWith(
151
+ "/home/testuser/.openclaw/bin/cloudflared",
152
+ expect.any(Uint8Array),
153
+ );
154
+ expect(chmodMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared", 0o755);
155
+ expect(logger.info).toHaveBeenCalledTimes(2);
156
+
157
+ vi.unstubAllGlobals();
158
+ });
159
+
160
+ it("downloads and extracts macOS tgz", async () => {
161
+ Object.defineProperty(process, "platform", { value: "darwin" });
162
+ Object.defineProperty(process, "arch", { value: "arm64" });
163
+
164
+ // Make execFile (used by promisify) call the callback immediately.
165
+ // promisify looks for the last function argument as the callback.
166
+ execFileMock.mockImplementation((...args: unknown[]) => {
167
+ const cb = args[args.length - 1];
168
+ if (typeof cb === "function") (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, "", "");
169
+ });
170
+
171
+ const binaryData = new Uint8Array([1, 2, 3]);
172
+ const fetchMock = vi.fn().mockResolvedValue({
173
+ ok: true,
174
+ arrayBuffer: () => Promise.resolve(binaryData.buffer),
175
+ });
176
+ vi.stubGlobal("fetch", fetchMock);
177
+
178
+ const { installCloudflared } = await import("./cloudflared.js");
179
+ const logger = { info: vi.fn() };
180
+ const result = await installCloudflared(logger);
181
+
182
+ expect(result).toBe("/home/testuser/.openclaw/bin/cloudflared");
183
+ expect(fetchMock).toHaveBeenCalledWith(
184
+ "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
185
+ { redirect: "follow" },
186
+ );
187
+ // tgz should be written, tar extracted, then tgz deleted
188
+ expect(writeFileMock).toHaveBeenCalledWith(
189
+ "/home/testuser/.openclaw/bin/cloudflared.tgz",
190
+ expect.any(Uint8Array),
191
+ );
192
+ expect(unlinkMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared.tgz");
193
+ expect(chmodMock).toHaveBeenCalledWith("/home/testuser/.openclaw/bin/cloudflared", 0o755);
194
+
195
+ vi.unstubAllGlobals();
196
+ });
197
+
198
+ it("throws on unsupported platform", async () => {
199
+ Object.defineProperty(process, "platform", { value: "win32" });
200
+ Object.defineProperty(process, "arch", { value: "x64" });
201
+
202
+ const { installCloudflared } = await import("./cloudflared.js");
203
+ await expect(installCloudflared()).rejects.toThrow(/Unsupported platform/);
204
+ });
205
+
206
+ it("throws on download failure", async () => {
207
+ Object.defineProperty(process, "platform", { value: "linux" });
208
+ Object.defineProperty(process, "arch", { value: "x64" });
209
+
210
+ const fetchMock = vi.fn().mockResolvedValue({
211
+ ok: false,
212
+ status: 404,
213
+ });
214
+ vi.stubGlobal("fetch", fetchMock);
215
+
216
+ const { installCloudflared } = await import("./cloudflared.js");
217
+ await expect(installCloudflared()).rejects.toThrow(/Failed to download cloudflared: HTTP 404/);
218
+
219
+ vi.unstubAllGlobals();
220
+ });
73
221
  });
74
222
 
75
223
  describe("startCloudflaredTunnel", () => {
@@ -1,5 +1,11 @@
1
1
  import { execFile, spawn, type ChildProcess } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { chmod, mkdir, unlink, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ const execFilePromise = promisify(execFile);
3
9
 
4
10
  type ExecResult = { stdout: string; stderr: string };
5
11
  type ExecFn = (cmd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<ExecResult>;
@@ -56,6 +62,7 @@ export async function findCloudflaredBinary(
56
62
 
57
63
  // Strategy 2: Known install paths
58
64
  const knownPaths = [
65
+ path.join(os.homedir(), ".openclaw", "bin", "cloudflared"),
59
66
  "/usr/local/bin/cloudflared",
60
67
  "/usr/bin/cloudflared",
61
68
  "/opt/homebrew/bin/cloudflared",
@@ -69,6 +76,59 @@ export async function findCloudflaredBinary(
69
76
  return null;
70
77
  }
71
78
 
79
+ /**
80
+ * Download and install the cloudflared binary from GitHub releases to ~/.openclaw/bin/.
81
+ */
82
+ export async function installCloudflared(logger?: {
83
+ info: (msg: string) => void;
84
+ }): Promise<string> {
85
+ const platform = process.platform;
86
+ const arch = process.arch;
87
+
88
+ const archMap: Record<string, string> = {
89
+ x64: "amd64",
90
+ arm64: "arm64",
91
+ arm: "arm",
92
+ ia32: "386",
93
+ };
94
+ const cfArch = archMap[arch];
95
+ if (!cfArch) throw new Error(`Unsupported architecture: ${arch}`);
96
+
97
+ let filename: string;
98
+ if (platform === "linux") {
99
+ filename = `cloudflared-linux-${cfArch}`;
100
+ } else if (platform === "darwin") {
101
+ filename = `cloudflared-darwin-${cfArch}.tgz`;
102
+ } else {
103
+ throw new Error(`Unsupported platform for auto-install: ${platform}`);
104
+ }
105
+
106
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${filename}`;
107
+
108
+ const installDir = path.join(os.homedir(), ".openclaw", "bin");
109
+ await mkdir(installDir, { recursive: true });
110
+ const installPath = path.join(installDir, "cloudflared");
111
+
112
+ logger?.info(`cloudflared not found, downloading from ${url}...`);
113
+
114
+ const res = await fetch(url, { redirect: "follow" });
115
+ if (!res.ok) throw new Error(`Failed to download cloudflared: HTTP ${res.status}`);
116
+ const data = new Uint8Array(await res.arrayBuffer());
117
+
118
+ if (platform === "darwin") {
119
+ const tgzPath = installPath + ".tgz";
120
+ await writeFile(tgzPath, data);
121
+ await execFilePromise("tar", ["-xzf", tgzPath, "-C", installDir]);
122
+ await unlink(tgzPath);
123
+ } else {
124
+ await writeFile(installPath, data);
125
+ }
126
+
127
+ await chmod(installPath, 0o755);
128
+ logger?.info(`cloudflared installed to ${installPath}`);
129
+ return installPath;
130
+ }
131
+
72
132
  let cachedCloudflaredBinary: string | null = null;
73
133
 
74
134
  export async function getCloudflaredBinary(exec: ExecFn = defaultExec): Promise<string> {
@@ -102,9 +162,13 @@ export async function startCloudflaredTunnel(opts: {
102
162
  token: string;
103
163
  timeoutMs?: number;
104
164
  exec?: ExecFn;
165
+ logger?: { info: (msg: string) => void };
105
166
  }): Promise<CloudflaredTunnel> {
106
167
  const exec = opts.exec ?? defaultExec;
107
- const bin = await getCloudflaredBinary(exec);
168
+ let bin = await findCloudflaredBinary(exec);
169
+ if (!bin) {
170
+ bin = await installCloudflared(opts.logger);
171
+ }
108
172
  const timeoutMs = opts.timeoutMs ?? 30_000;
109
173
  const stderr: string[] = [];
110
174
 
@@ -86,6 +86,7 @@ describe("startGatewayCloudflareExposure", () => {
86
86
  expect(startCloudflaredTunnelMock).toHaveBeenCalledWith({
87
87
  token: "test-token",
88
88
  timeoutMs: 30_000,
89
+ logger: log,
89
90
  });
90
91
  expect(log.info).toHaveBeenCalledWith(
91
92
  expect.stringContaining("connectorId=abc-123"),
@@ -30,6 +30,7 @@ export async function startGatewayCloudflareExposure(params: {
30
30
  const tunnel = await startCloudflaredTunnel({
31
31
  token: params.tunnelToken,
32
32
  timeoutMs: 30_000,
33
+ logger: params.logCloudflare,
33
34
  });
34
35
  params.logCloudflare.info(
35
36
  `managed tunnel running (connectorId=${tunnel.connectorId ?? "unknown"}, pid=${tunnel.pid ?? "unknown"})`,