openclaw-cloudflare 0.1.0

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.
@@ -0,0 +1,280 @@
1
+ import crypto from "node:crypto";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createCloudflareAccessVerifier } from "./access.js";
4
+
5
+ // Generate a test RSA keypair for signing JWTs
6
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
7
+ modulusLength: 2048,
8
+ publicKeyEncoding: { type: "spki", format: "pem" },
9
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
10
+ });
11
+
12
+ // Export as JWK for the mock JWKS endpoint
13
+ const publicJwk = crypto.createPublicKey(publicKey).export({ format: "jwk" }) as {
14
+ kty: string;
15
+ n: string;
16
+ e: string;
17
+ };
18
+
19
+ const TEST_KID = "test-key-1";
20
+ const TEST_TEAM_DOMAIN = "myteam";
21
+ const TEST_ISSUER = `https://${TEST_TEAM_DOMAIN}.cloudflareaccess.com`;
22
+ const TEST_AUDIENCE = "test-aud-tag";
23
+
24
+ function base64UrlEncode(data: Buffer | string): string {
25
+ const buf = typeof data === "string" ? Buffer.from(data) : data;
26
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
+ }
28
+
29
+ async function createTestJwt(opts: {
30
+ email?: string;
31
+ iss?: string;
32
+ aud?: string | string[];
33
+ exp?: number;
34
+ kid?: string;
35
+ }): Promise<string> {
36
+ const header = {
37
+ alg: "RS256",
38
+ typ: "JWT",
39
+ kid: opts.kid ?? TEST_KID,
40
+ };
41
+
42
+ const payload = {
43
+ email: opts.email ?? "user@example.com",
44
+ sub: "user-123",
45
+ iss: opts.iss ?? TEST_ISSUER,
46
+ aud: opts.aud ?? TEST_AUDIENCE,
47
+ exp: opts.exp ?? Math.floor(Date.now() / 1000) + 3600,
48
+ iat: Math.floor(Date.now() / 1000),
49
+ };
50
+
51
+ const headerB64 = base64UrlEncode(JSON.stringify(header));
52
+ const payloadB64 = base64UrlEncode(JSON.stringify(payload));
53
+ const signingInput = `${headerB64}.${payloadB64}`;
54
+
55
+ const sign = crypto.createSign("RSA-SHA256");
56
+ sign.update(signingInput);
57
+ const signature = sign.sign(privateKey);
58
+
59
+ return `${signingInput}.${base64UrlEncode(signature)}`;
60
+ }
61
+
62
+ function createMockFetch(): typeof globalThis.fetch {
63
+ return vi.fn(async (url: string | URL | Request) => {
64
+ const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
65
+ if (urlStr.includes("/cdn-cgi/access/certs")) {
66
+ return new Response(
67
+ JSON.stringify({
68
+ keys: [
69
+ {
70
+ kty: publicJwk.kty,
71
+ kid: TEST_KID,
72
+ alg: "RS256",
73
+ use: "sig",
74
+ n: publicJwk.n,
75
+ e: publicJwk.e,
76
+ },
77
+ ],
78
+ }),
79
+ { status: 200 },
80
+ );
81
+ }
82
+ return new Response("Not Found", { status: 404 });
83
+ }) as typeof globalThis.fetch;
84
+ }
85
+
86
+ describe("cloudflare-access verifier", () => {
87
+ it("verifies a valid JWT and returns user email", async () => {
88
+ const verifier = createCloudflareAccessVerifier({
89
+ teamDomain: TEST_TEAM_DOMAIN,
90
+ audience: TEST_AUDIENCE,
91
+ fetchFn: createMockFetch(),
92
+ });
93
+
94
+ const token = await createTestJwt({});
95
+ const result = await verifier.verify(token);
96
+
97
+ expect(result).not.toBeNull();
98
+ expect(result!.email).toBe("user@example.com");
99
+ });
100
+
101
+ it("rejects expired JWT", async () => {
102
+ const verifier = createCloudflareAccessVerifier({
103
+ teamDomain: TEST_TEAM_DOMAIN,
104
+ audience: TEST_AUDIENCE,
105
+ fetchFn: createMockFetch(),
106
+ });
107
+
108
+ const token = await createTestJwt({
109
+ exp: Math.floor(Date.now() / 1000) - 3600,
110
+ });
111
+ const result = await verifier.verify(token);
112
+
113
+ expect(result).toBeNull();
114
+ });
115
+
116
+ it("rejects JWT with wrong issuer", async () => {
117
+ const verifier = createCloudflareAccessVerifier({
118
+ teamDomain: TEST_TEAM_DOMAIN,
119
+ audience: TEST_AUDIENCE,
120
+ fetchFn: createMockFetch(),
121
+ });
122
+
123
+ const token = await createTestJwt({
124
+ iss: "https://evil.cloudflareaccess.com",
125
+ });
126
+ const result = await verifier.verify(token);
127
+
128
+ expect(result).toBeNull();
129
+ });
130
+
131
+ it("rejects JWT with wrong audience when audience is configured", async () => {
132
+ const verifier = createCloudflareAccessVerifier({
133
+ teamDomain: TEST_TEAM_DOMAIN,
134
+ audience: TEST_AUDIENCE,
135
+ fetchFn: createMockFetch(),
136
+ });
137
+
138
+ const token = await createTestJwt({
139
+ aud: "wrong-audience",
140
+ });
141
+ const result = await verifier.verify(token);
142
+
143
+ expect(result).toBeNull();
144
+ });
145
+
146
+ it("accepts any audience when audience is not configured", async () => {
147
+ const verifier = createCloudflareAccessVerifier({
148
+ teamDomain: TEST_TEAM_DOMAIN,
149
+ fetchFn: createMockFetch(),
150
+ });
151
+
152
+ const token = await createTestJwt({
153
+ aud: "any-audience",
154
+ });
155
+ const result = await verifier.verify(token);
156
+
157
+ expect(result).not.toBeNull();
158
+ expect(result!.email).toBe("user@example.com");
159
+ });
160
+
161
+ it("rejects malformed token", async () => {
162
+ const verifier = createCloudflareAccessVerifier({
163
+ teamDomain: TEST_TEAM_DOMAIN,
164
+ fetchFn: createMockFetch(),
165
+ });
166
+
167
+ const result = await verifier.verify("not.a.valid.jwt");
168
+ expect(result).toBeNull();
169
+ });
170
+
171
+ it("rejects token with unknown kid", async () => {
172
+ const verifier = createCloudflareAccessVerifier({
173
+ teamDomain: TEST_TEAM_DOMAIN,
174
+ fetchFn: createMockFetch(),
175
+ });
176
+
177
+ const token = await createTestJwt({ kid: "unknown-key-id" });
178
+ const result = await verifier.verify(token);
179
+
180
+ expect(result).toBeNull();
181
+ });
182
+
183
+ it("rejects JWT with tampered signature", async () => {
184
+ const verifier = createCloudflareAccessVerifier({
185
+ teamDomain: TEST_TEAM_DOMAIN,
186
+ audience: TEST_AUDIENCE,
187
+ fetchFn: createMockFetch(),
188
+ });
189
+
190
+ const token = await createTestJwt({});
191
+ // Tamper with the signature
192
+ const parts = token.split(".");
193
+ parts[2] = base64UrlEncode(crypto.randomBytes(256));
194
+ const tampered = parts.join(".");
195
+
196
+ const result = await verifier.verify(tampered);
197
+ expect(result).toBeNull();
198
+ });
199
+
200
+ it("rejects JWT with unsupported algorithm", async () => {
201
+ const verifier = createCloudflareAccessVerifier({
202
+ teamDomain: TEST_TEAM_DOMAIN,
203
+ audience: TEST_AUDIENCE,
204
+ fetchFn: createMockFetch(),
205
+ });
206
+
207
+ // Create a token with alg: "none"
208
+ const header = { alg: "none", typ: "JWT", kid: TEST_KID };
209
+ const payload = {
210
+ email: "user@example.com",
211
+ sub: "user-123",
212
+ iss: TEST_ISSUER,
213
+ aud: TEST_AUDIENCE,
214
+ exp: Math.floor(Date.now() / 1000) + 3600,
215
+ iat: Math.floor(Date.now() / 1000),
216
+ };
217
+ const headerB64 = base64UrlEncode(JSON.stringify(header));
218
+ const payloadB64 = base64UrlEncode(JSON.stringify(payload));
219
+ const token = `${headerB64}.${payloadB64}.${base64UrlEncode("fake")}`;
220
+
221
+ const result = await verifier.verify(token);
222
+ expect(result).toBeNull();
223
+ });
224
+
225
+ it("rejects JWT with tampered payload but original signature", async () => {
226
+ const verifier = createCloudflareAccessVerifier({
227
+ teamDomain: TEST_TEAM_DOMAIN,
228
+ audience: TEST_AUDIENCE,
229
+ fetchFn: createMockFetch(),
230
+ });
231
+
232
+ const token = await createTestJwt({});
233
+ const parts = token.split(".");
234
+
235
+ // Tamper with the payload (change email) but keep original signature
236
+ const tamperedPayload = {
237
+ email: "attacker@evil.com",
238
+ sub: "user-123",
239
+ iss: TEST_ISSUER,
240
+ aud: TEST_AUDIENCE,
241
+ exp: Math.floor(Date.now() / 1000) + 3600,
242
+ iat: Math.floor(Date.now() / 1000),
243
+ };
244
+ parts[1] = base64UrlEncode(JSON.stringify(tamperedPayload));
245
+ const tampered = parts.join(".");
246
+
247
+ const result = await verifier.verify(tampered);
248
+ expect(result).toBeNull();
249
+ });
250
+
251
+ it("rejects JWT with no email claim", async () => {
252
+ const verifier = createCloudflareAccessVerifier({
253
+ teamDomain: TEST_TEAM_DOMAIN,
254
+ fetchFn: createMockFetch(),
255
+ });
256
+
257
+ // Create a token with no email
258
+ const header = {
259
+ alg: "RS256",
260
+ typ: "JWT",
261
+ kid: TEST_KID,
262
+ };
263
+ const payload = {
264
+ sub: "service-token",
265
+ iss: TEST_ISSUER,
266
+ exp: Math.floor(Date.now() / 1000) + 3600,
267
+ iat: Math.floor(Date.now() / 1000),
268
+ };
269
+ const headerB64 = base64UrlEncode(JSON.stringify(header));
270
+ const payloadB64 = base64UrlEncode(JSON.stringify(payload));
271
+ const signingInput = `${headerB64}.${payloadB64}`;
272
+ const sign = crypto.createSign("RSA-SHA256");
273
+ sign.update(signingInput);
274
+ const signature = sign.sign(privateKey);
275
+ const token = `${signingInput}.${base64UrlEncode(signature)}`;
276
+
277
+ const result = await verifier.verify(token);
278
+ expect(result).toBeNull();
279
+ });
280
+ });
@@ -0,0 +1,210 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export type CloudflareAccessUser = {
4
+ email: string;
5
+ };
6
+
7
+ export type CloudflareAccessVerifier = {
8
+ verify(token: string): Promise<CloudflareAccessUser | null>;
9
+ };
10
+
11
+ type JwksKey = {
12
+ kty: string;
13
+ kid: string;
14
+ alg?: string;
15
+ n?: string;
16
+ e?: string;
17
+ crv?: string;
18
+ x?: string;
19
+ y?: string;
20
+ use?: string;
21
+ };
22
+
23
+ type JwksResponse = {
24
+ keys: JwksKey[];
25
+ };
26
+
27
+ type JwtHeader = {
28
+ alg: string;
29
+ kid?: string;
30
+ typ?: string;
31
+ };
32
+
33
+ type JwtPayload = {
34
+ email?: string;
35
+ sub?: string;
36
+ iss?: string;
37
+ aud?: string | string[];
38
+ exp?: number;
39
+ iat?: number;
40
+ [key: string]: unknown;
41
+ };
42
+
43
+ const JWKS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
44
+
45
+ function base64UrlDecode(input: string): Buffer {
46
+ // Replace URL-safe chars and add padding
47
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
48
+ const paddedLength = padded.length + ((4 - (padded.length % 4)) % 4);
49
+ return Buffer.from(padded.padEnd(paddedLength, "="), "base64");
50
+ }
51
+
52
+ function decodeJwtPart<T>(part: string): T {
53
+ return JSON.parse(base64UrlDecode(part).toString("utf8")) as T;
54
+ }
55
+
56
+ function jwkToCryptoKeyAlgorithm(jwk: JwksKey): RsaHashedImportParams | EcKeyImportParams {
57
+ if (jwk.kty === "RSA") {
58
+ return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
59
+ }
60
+ if (jwk.kty === "EC") {
61
+ const namedCurve = jwk.crv ?? "P-256";
62
+ return { name: "ECDSA", namedCurve };
63
+ }
64
+ throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
65
+ }
66
+
67
+ function verifyAlgorithmForKey(jwk: JwksKey): AlgorithmIdentifier | RsaPssParams | EcdsaParams {
68
+ if (jwk.kty === "RSA") {
69
+ return { name: "RSASSA-PKCS1-v1_5" };
70
+ }
71
+ if (jwk.kty === "EC") {
72
+ return { name: "ECDSA", hash: "SHA-256" };
73
+ }
74
+ throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
75
+ }
76
+
77
+ async function importJwk(jwk: JwksKey): Promise<crypto.webcrypto.CryptoKey> {
78
+ const algorithm = jwkToCryptoKeyAlgorithm(jwk);
79
+ // Only pass the fields relevant for key import
80
+ const keyData = {
81
+ kty: jwk.kty,
82
+ alg: jwk.alg,
83
+ use: jwk.use,
84
+ ...(jwk.kty === "RSA" ? { n: jwk.n, e: jwk.e } : {}),
85
+ ...(jwk.kty === "EC" ? { crv: jwk.crv, x: jwk.x, y: jwk.y } : {}),
86
+ };
87
+ return await crypto.subtle.importKey("jwk", keyData, algorithm, false, ["verify"]);
88
+ }
89
+
90
+ /**
91
+ * Create a Cloudflare Access JWT verifier.
92
+ *
93
+ * Fetches JWKS from `https://<teamDomain>.cloudflareaccess.com/cdn-cgi/access/certs`
94
+ * and verifies JWTs using Node's built-in WebCrypto API.
95
+ */
96
+ export function createCloudflareAccessVerifier(opts: {
97
+ teamDomain: string;
98
+ audience?: string;
99
+ /** Override fetch for testing. */
100
+ fetchFn?: typeof globalThis.fetch;
101
+ }): CloudflareAccessVerifier {
102
+ const issuer = `https://${opts.teamDomain}.cloudflareaccess.com`;
103
+ const jwksUrl = `${issuer}/cdn-cgi/access/certs`;
104
+ const audience = opts.audience;
105
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
106
+
107
+ let cachedKeys: Map<string, JwksKey> | null = null;
108
+ let cachedAt = 0;
109
+
110
+ async function fetchJwks(): Promise<Map<string, JwksKey>> {
111
+ const now = Date.now();
112
+ if (cachedKeys && now - cachedAt < JWKS_CACHE_TTL_MS) {
113
+ return cachedKeys;
114
+ }
115
+ const res = await fetchFn(jwksUrl);
116
+ if (!res.ok) {
117
+ throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${res.status}`);
118
+ }
119
+ const body = (await res.json()) as JwksResponse;
120
+ const keyMap = new Map<string, JwksKey>();
121
+ for (const key of body.keys ?? []) {
122
+ if (key.kid) {
123
+ keyMap.set(key.kid, key);
124
+ }
125
+ }
126
+ cachedKeys = keyMap;
127
+ cachedAt = now;
128
+ return keyMap;
129
+ }
130
+
131
+ async function verify(token: string): Promise<CloudflareAccessUser | null> {
132
+ try {
133
+ const parts = token.split(".");
134
+ if (parts.length !== 3) {
135
+ return null;
136
+ }
137
+
138
+ const header = decodeJwtPart<JwtHeader>(parts[0]);
139
+ const payload = decodeJwtPart<JwtPayload>(parts[1]);
140
+
141
+ // Validate issuer
142
+ if (payload.iss !== issuer) {
143
+ return null;
144
+ }
145
+
146
+ // Validate expiry
147
+ if (typeof payload.exp === "number" && payload.exp < Date.now() / 1000) {
148
+ return null;
149
+ }
150
+
151
+ // Validate audience (if configured)
152
+ if (audience) {
153
+ const aud = payload.aud;
154
+ const audMatch = Array.isArray(aud) ? aud.includes(audience) : aud === audience;
155
+ if (!audMatch) {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // Validate algorithm — only RS256 and ES256 are supported
161
+ const SUPPORTED_ALGS = new Set(["RS256", "ES256"]);
162
+ if (!SUPPORTED_ALGS.has(header.alg)) {
163
+ return null;
164
+ }
165
+
166
+ // Find the signing key
167
+ let keys = await fetchJwks();
168
+ let jwk = header.kid ? keys.get(header.kid) : undefined;
169
+
170
+ // If key not found, refresh JWKS (key rotation)
171
+ if (!jwk && header.kid) {
172
+ cachedKeys = null;
173
+ keys = await fetchJwks();
174
+ jwk = keys.get(header.kid);
175
+ }
176
+ if (!jwk) {
177
+ return null;
178
+ }
179
+
180
+ // Verify algorithm matches key type
181
+ const expectedAlg = jwk.kty === "RSA" ? "RS256" : jwk.kty === "EC" ? "ES256" : null;
182
+ if (header.alg !== expectedAlg) {
183
+ return null;
184
+ }
185
+
186
+ // Verify signature using WebCrypto
187
+ const cryptoKey = await importJwk(jwk);
188
+ const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
189
+ const signature = new Uint8Array(base64UrlDecode(parts[2]));
190
+ const algorithm = verifyAlgorithmForKey(jwk);
191
+
192
+ const valid = await crypto.subtle.verify(algorithm, cryptoKey, signature, signatureInput);
193
+
194
+ if (!valid) {
195
+ return null;
196
+ }
197
+
198
+ const email = typeof payload.email === "string" ? payload.email : undefined;
199
+ if (!email) {
200
+ return null;
201
+ }
202
+
203
+ return { email };
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ return { verify };
210
+ }
@@ -0,0 +1,176 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { describe, expect, it, vi, beforeEach } from "vitest";
4
+
5
+ // Mock spawn
6
+ const spawnMock = vi.fn();
7
+ vi.mock("node:child_process", () => ({
8
+ execFile: vi.fn(),
9
+ spawn: (...args: unknown[]) => spawnMock(...args),
10
+ }));
11
+
12
+ // Mock existsSync
13
+ const existsSyncMock = vi.fn<(p: string) => boolean>(() => true);
14
+ vi.mock("node:fs", () => ({
15
+ existsSync: (p: string) => existsSyncMock(p),
16
+ }));
17
+
18
+ // Shared exec mock for findCloudflaredBinary
19
+ const execMock = vi.fn();
20
+
21
+ describe("findCloudflaredBinary", () => {
22
+ beforeEach(() => {
23
+ vi.resetModules();
24
+ vi.restoreAllMocks();
25
+ delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
26
+ });
27
+
28
+ it("returns environment override when set", async () => {
29
+ process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/custom/cloudflared";
30
+ const { findCloudflaredBinary } = await import("./cloudflared.js");
31
+ const result = await findCloudflaredBinary(execMock);
32
+ expect(result).toBe("/custom/cloudflared");
33
+ });
34
+
35
+ it("finds cloudflared via which", async () => {
36
+ execMock.mockImplementation((cmd: string, _args: string[]) => {
37
+ if (cmd === "which") {
38
+ return Promise.resolve({ stdout: "/usr/local/bin/cloudflared\n", stderr: "" });
39
+ }
40
+ // --version check
41
+ return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
42
+ });
43
+ existsSyncMock.mockReturnValue(true);
44
+
45
+ const { findCloudflaredBinary } = await import("./cloudflared.js");
46
+ const result = await findCloudflaredBinary(execMock);
47
+ expect(result).toBe("/usr/local/bin/cloudflared");
48
+ });
49
+
50
+ it("falls back to known paths when which fails", async () => {
51
+ execMock.mockImplementation((cmd: string, _args: string[]) => {
52
+ if (cmd === "which") {
53
+ return Promise.reject(new Error("not found"));
54
+ }
55
+ // --version check for known path
56
+ return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
57
+ });
58
+ existsSyncMock.mockImplementation((p: string) => p === "/usr/local/bin/cloudflared");
59
+
60
+ const { findCloudflaredBinary } = await import("./cloudflared.js");
61
+ const result = await findCloudflaredBinary(execMock);
62
+ expect(result).toBe("/usr/local/bin/cloudflared");
63
+ });
64
+
65
+ it("returns null when binary is not found", async () => {
66
+ execMock.mockRejectedValue(new Error("not found"));
67
+ existsSyncMock.mockReturnValue(false);
68
+
69
+ const { findCloudflaredBinary } = await import("./cloudflared.js");
70
+ const result = await findCloudflaredBinary(execMock);
71
+ expect(result).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("startCloudflaredTunnel", () => {
76
+ beforeEach(() => {
77
+ vi.resetModules();
78
+ vi.restoreAllMocks();
79
+ delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
80
+ });
81
+
82
+ function createMockProcess(): ChildProcess & {
83
+ _emit: (event: string, ...args: unknown[]) => void;
84
+ _emitStderr: (data: string) => void;
85
+ } {
86
+ const events: Record<string, Array<(...args: unknown[]) => void>> = {};
87
+ const stdoutEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
88
+ const stderrEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
89
+
90
+ const mockStdout = {
91
+ setEncoding: vi.fn(),
92
+ on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
93
+ stdoutEvents[event] = stdoutEvents[event] ?? [];
94
+ stdoutEvents[event].push(cb);
95
+ }),
96
+ };
97
+ const mockStderr = {
98
+ setEncoding: vi.fn(),
99
+ on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
100
+ stderrEvents[event] = stderrEvents[event] ?? [];
101
+ stderrEvents[event].push(cb);
102
+ }),
103
+ };
104
+
105
+ return {
106
+ pid: 12345,
107
+ killed: false,
108
+ stdout: mockStdout as unknown as Readable,
109
+ stderr: mockStderr as unknown as Readable,
110
+ kill: vi.fn(),
111
+ on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
112
+ events[event] = events[event] ?? [];
113
+ events[event].push(cb);
114
+ }),
115
+ once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
116
+ events[event] = events[event] ?? [];
117
+ events[event].push(cb);
118
+ }),
119
+ _emit: (event: string, ...args: unknown[]) => {
120
+ for (const cb of events[event] ?? []) {
121
+ cb(...args);
122
+ }
123
+ },
124
+ _emitStderr: (data: string) => {
125
+ for (const cb of stderrEvents.data ?? []) {
126
+ cb(data);
127
+ }
128
+ },
129
+ } as unknown as ChildProcess & {
130
+ _emit: (event: string, ...args: unknown[]) => void;
131
+ _emitStderr: (data: string) => void;
132
+ };
133
+ }
134
+
135
+ it("starts tunnel and parses connector ID", async () => {
136
+ const mockChild = createMockProcess();
137
+
138
+ spawnMock.mockReturnValue(mockChild);
139
+ process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
140
+
141
+ const { startCloudflaredTunnel } = await import("./cloudflared.js");
142
+
143
+ const tunnelPromise = startCloudflaredTunnel({
144
+ token: "test-token",
145
+ timeoutMs: 5000,
146
+ });
147
+
148
+ // Simulate cloudflared registering a connection
149
+ await new Promise((r) => setTimeout(r, 50));
150
+ mockChild._emitStderr("INF Registered tunnel connection connectorID=abc123-def456");
151
+
152
+ const tunnel = await tunnelPromise;
153
+ expect(tunnel.connectorId).toBe("abc123-def456");
154
+ expect(tunnel.pid).toBe(12345);
155
+ expect(typeof tunnel.stop).toBe("function");
156
+ });
157
+
158
+ it("throws when tunnel exits before registering", async () => {
159
+ const mockChild = createMockProcess();
160
+
161
+ spawnMock.mockReturnValue(mockChild);
162
+ process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
163
+
164
+ const { startCloudflaredTunnel } = await import("./cloudflared.js");
165
+
166
+ const tunnelPromise = startCloudflaredTunnel({
167
+ token: "bad-token",
168
+ timeoutMs: 5000,
169
+ });
170
+
171
+ await new Promise((r) => setTimeout(r, 50));
172
+ mockChild._emit("exit", 1, null);
173
+
174
+ await expect(tunnelPromise).rejects.toThrow(/cloudflared exited/);
175
+ });
176
+ });