@chenacc/agentbridge 0.1.3 → 0.2.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.
package/src/tunnel.js ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Cloudflare quick tunnel, so the phone does not have to share a Wi-Fi with the PC.
3
+ *
4
+ * A LAN-only QR code is a dead end the moment the user leaves the house, and it is
5
+ * also what WeChat likes least (an http URL with a bare IP address in it). A
6
+ * `cloudflared` quick tunnel hands back a real https hostname, which the phone can
7
+ * open from anywhere — at the cost of two honest caveats:
8
+ *
9
+ * - the hostname is random and changes whenever the tunnel is recreated, so a
10
+ * phone bookmark only lasts as long as the process keeps running;
11
+ * - it is a public URL, so the bridge in front of it must keep its access key on.
12
+ *
13
+ * Everything here is deliberately side-effect free until called, and every
14
+ * environment-dependent lookup takes an injectable dependency, because this code
15
+ * decides whether a public URL exists at all and that is worth testing.
16
+ */
17
+ import { spawn } from "node:child_process";
18
+ import { randomBytes } from "node:crypto";
19
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
20
+ import https from "node:https";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import tls from "node:tls";
24
+
25
+ /**
26
+ * A quick tunnel hostname.
27
+ *
28
+ * The negative lookahead is load-bearing: cloudflared's own failure messages quote
29
+ * `https://api.trycloudflare.com/tunnel`, which matches a naive pattern and would
30
+ * be mistaken for the tunnel the phone should open.
31
+ */
32
+ export const TUNNEL_URL_PATTERN = /https:\/\/(?!api\.)[-a-z0-9]+\.trycloudflare\.com/;
33
+
34
+ // cloudflared writes the banner to stderr, but stdout is cheap to read too and
35
+ // some builds differ.
36
+ export function extractTunnelUrl(text) {
37
+ const match = TUNNEL_URL_PATTERN.exec(String(text ?? ""));
38
+ return match ? match[0] : null;
39
+ }
40
+
41
+ /**
42
+ * Where `cloudflared` usually lives on Windows, best first.
43
+ *
44
+ * PATH is included because `winget` installs a shim there, and the two Program
45
+ * Files directories cover the MSI, which does not touch PATH from a non-elevated
46
+ * shell. `AGENTBRIDGE_CLOUDFLARED` wins over all of it for unusual installs.
47
+ */
48
+ export function cloudflaredCandidates(env = process.env, home = os.homedir()) {
49
+ const candidates = [];
50
+ if (env.AGENTBRIDGE_CLOUDFLARED) candidates.push(env.AGENTBRIDGE_CLOUDFLARED);
51
+ for (const dir of String(env.PATH ?? "").split(path.delimiter)) {
52
+ if (!dir) continue;
53
+ candidates.push(path.join(dir, "cloudflared.exe"), path.join(dir, "cloudflared"));
54
+ }
55
+ if (env.ProgramFiles) candidates.push(path.join(env.ProgramFiles, "cloudflared", "cloudflared.exe"));
56
+ if (env["ProgramFiles(x86)"]) candidates.push(path.join(env["ProgramFiles(x86)"], "cloudflared", "cloudflared.exe"));
57
+ if (env.LOCALAPPDATA) candidates.push(path.join(env.LOCALAPPDATA, "Microsoft", "WinGet", "Links", "cloudflared.exe"));
58
+ // The bridge's own data directory is a last resort for a portable copy the user
59
+ // downloaded by hand.
60
+ candidates.push(path.join(home, ".agentbridge", "bin", "cloudflared.exe"));
61
+ return candidates;
62
+ }
63
+
64
+ export function findCloudflared({ env = process.env, home = os.homedir(), exists = existsSync } = {}) {
65
+ for (const candidate of cloudflaredCandidates(env, home)) {
66
+ try {
67
+ if (exists(candidate)) return candidate;
68
+ } catch {
69
+ // An unreadable PATH entry is not worth failing the whole search.
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** The official release asset for this machine, or null when there is none. */
76
+ export function cloudflaredAssetUrl({ platform = process.platform, arch = process.arch } = {}) {
77
+ if (platform === "win32" && arch === "x64") {
78
+ return "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe";
79
+ }
80
+ if (platform === "win32" && arch === "arm64") {
81
+ return "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-arm64.exe";
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export function cloudflaredBinaryPath(dataDir, { platform = process.platform } = {}) {
87
+ return path.join(dataDir, "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
88
+ }
89
+
90
+ function cloudflaredVersionPath(dataDir) {
91
+ return path.join(dataDir, "bin", "cloudflared.json");
92
+ }
93
+
94
+ /**
95
+ * Proves a downloaded file is the real cloudflared before anything runs through it.
96
+ *
97
+ * `--version` is the honest test: it exercises the binary the tunnel is about to
98
+ * use, and the banner ("cloudflared version 2026.10.7 ...") is a shape no HTML
99
+ * error page or truncated download can produce.
100
+ */
101
+ export async function cloudflaredVersion(command, { spawnImpl = spawn, timeoutMs = 20_000 } = {}) {
102
+ return new Promise((resolve) => {
103
+ let output = "";
104
+ let child;
105
+ try {
106
+ child = spawnImpl(command, ["--version"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
107
+ } catch {
108
+ return resolve(null);
109
+ }
110
+ const done = (value) => {
111
+ clearTimeout(timer);
112
+ resolve(value);
113
+ };
114
+ const timer = setTimeout(() => {
115
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
116
+ done(null);
117
+ }, timeoutMs);
118
+ timer.unref?.();
119
+ child.stdout?.on("data", (chunk) => { output += String(chunk); });
120
+ child.stderr?.on("data", (chunk) => { output += String(chunk); });
121
+ child.once?.("error", () => done(null));
122
+ child.once?.("exit", () => {
123
+ const match = /cloudflared version v?(\d+\.\d+\.\d+\S*)/i.exec(output);
124
+ done(match ? match[1] : null);
125
+ });
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Fetch the official binary to `<dataDir>` and hand back its path.
131
+ *
132
+ * Two details make this work where a naive `fetch` would not: GitHub redirects to
133
+ * a versioned asset host, and on machines behind a TLS-inspecting proxy (common in
134
+ * corporate networks and in China) the OS trust store is the only thing that
135
+ * verifies the certificate — so the request carries the system roots alongside
136
+ * Node's bundled ones.
137
+ */
138
+ function downloadToFile(url, target, { log = () => {}, maxRedirects = 5, timeoutMs = 120_000 } = {}) {
139
+ return new Promise((resolve, reject) => {
140
+ const ca = (() => {
141
+ try {
142
+ const system = typeof tls.getCACertificates === "function" ? tls.getCACertificates("system") : [];
143
+ return [...tls.rootCertificates, ...system];
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ })();
148
+
149
+ const get = (current, redirectsLeft) => {
150
+ const request = https.get(current, {
151
+ ...(ca ? { ca } : {}),
152
+ headers: { "user-agent": "agentbridge-dsh-phone", accept: "application/octet-stream" },
153
+ timeout: timeoutMs,
154
+ }, (response) => {
155
+ const status = response.statusCode ?? 0;
156
+ if (status >= 300 && status < 400 && response.headers.location) {
157
+ response.resume();
158
+ if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
159
+ return get(new URL(response.headers.location, current).href, redirectsLeft - 1);
160
+ }
161
+ if (status !== 200) {
162
+ response.resume();
163
+ return reject(new Error(`HTTP ${status} downloading ${current}`));
164
+ }
165
+ const expected = Number(response.headers["content-length"] ?? 0);
166
+ if (expected) log(`downloading ${(expected / 1024 / 1024).toFixed(1)} MB from Cloudflare`);
167
+ const file = createWriteStream(target);
168
+ let written = 0;
169
+ let nextReport = 10 * 1024 * 1024;
170
+ response.on("data", (chunk) => {
171
+ written += chunk.length;
172
+ // A 55 MB download on a slow link otherwise looks like a hang.
173
+ if (written >= nextReport) {
174
+ nextReport += 10 * 1024 * 1024;
175
+ log(`cloudflared download: ${(written / 1024 / 1024).toFixed(0)} MB${expected ? ` of ${(expected / 1024 / 1024).toFixed(0)}` : ""}`);
176
+ }
177
+ });
178
+ response.pipe(file);
179
+ file.on("finish", () => file.close(() => {
180
+ // A truncated download that still "finished" is the failure mode that
181
+ // would otherwise land a corrupt exe on disk.
182
+ if (expected && written !== expected) return reject(new Error(`truncated download: ${written} of ${expected} bytes`));
183
+ resolve(written);
184
+ }));
185
+ file.on("error", reject);
186
+ response.on("error", reject);
187
+ });
188
+ request.on("timeout", () => request.destroy(new Error("download timed out")));
189
+ request.on("error", reject);
190
+ };
191
+
192
+ get(url, maxRedirects);
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The cloudflared this machine should use, downloaded on first need.
198
+ *
199
+ * A missing cloudflared used to mean "same Wi-Fi only", which made the plugin's
200
+ * remote mode an opt-in nobody opted into. Downloading the official build into the
201
+ * bridge's own data directory keeps it out of Program Files, needs no elevation,
202
+ * and can be removed by deleting one folder.
203
+ */
204
+ export async function ensureCloudflared({
205
+ dataDir,
206
+ env = process.env,
207
+ log = () => {},
208
+ platform = process.platform,
209
+ arch = process.arch,
210
+ exists = existsSync,
211
+ download = downloadToFile,
212
+ verify = cloudflaredVersion,
213
+ now = () => new Date().toISOString(),
214
+ } = {}) {
215
+ const override = String(env.AGENTBRIDGE_CLOUDFLARED ?? "").trim();
216
+ if (override) {
217
+ return exists(override) ? override : null;
218
+ }
219
+
220
+ const target = cloudflaredBinaryPath(dataDir, { platform });
221
+
222
+ if (exists(target)) {
223
+ const version = await verify(target);
224
+ if (version) return target;
225
+ log(`the downloaded cloudflared at ${target} does not run; removing it and fetching a fresh copy`);
226
+ try { rmSync(target, { force: true }); } catch { /* a locked file just fails the download below */ }
227
+ }
228
+
229
+ const url = String(env.AGENTBRIDGE_CLOUDFLARED_URL ?? "").trim() || cloudflaredAssetUrl({ platform, arch });
230
+ if (!url) {
231
+ log(`no official cloudflared build for ${platform}/${arch}; install one yourself and point AGENTBRIDGE_CLOUDFLARED at it`);
232
+ return null;
233
+ }
234
+ if (env.AGENTBRIDGE_NO_DOWNLOAD === "1") {
235
+ log("cloudflared is not installed and AGENTBRIDGE_NO_DOWNLOAD=1 forbids downloading it");
236
+ return null;
237
+ }
238
+
239
+ const temporary = `${target}.download`;
240
+ try {
241
+ mkdirSync(path.dirname(target), { recursive: true });
242
+ await download(url, temporary, { log });
243
+ const version = await verify(temporary);
244
+ if (!version) throw new Error("the downloaded file does not report a cloudflared version");
245
+ renameSync(temporary, target);
246
+ try {
247
+ writeFileSync(cloudflaredVersionPath(dataDir), JSON.stringify({ version, url, downloadedAt: now() }, null, 2), "utf8");
248
+ } catch {
249
+ // The record is a convenience, not a requirement.
250
+ }
251
+ log(`cloudflared ${version} is ready at ${target}`);
252
+ return target;
253
+ } catch (error) {
254
+ try { rmSync(temporary, { force: true }); } catch { /* nothing to clean */ }
255
+ log(`could not fetch cloudflared from ${url}: ${error instanceof Error ? error.message : String(error)}`);
256
+ return null;
257
+ }
258
+ }
259
+
260
+ export function accessKeyPath(dataDir) {
261
+ return path.join(dataDir, "access-key");
262
+ }
263
+
264
+ export function autostartTokenPath(dataDir) {
265
+ return path.join(dataDir, "autostart-token");
266
+ }
267
+
268
+ /**
269
+ * The key that gates a publicly exposed bridge.
270
+ *
271
+ * Read before generated, so the value survives restarts: the phone already holds a
272
+ * cookie for it, and regenerating on every launch would silently lock the phone
273
+ * out of its own bridge.
274
+ */
275
+ export function ensureAccessKey(dataDir, { readFile = readFileSync, writeFile = writeFileSync, mkdir = mkdirSync, random } = {}) {
276
+ return ensureSecret(accessKeyPath(dataDir), dataDir, { readFile, writeFile, mkdir, random });
277
+ }
278
+
279
+ /**
280
+ * The secret that lets this plugin ask its own bridge to restart.
281
+ *
282
+ * A restart is how the advertised address changes (the tunnel hostname only
283
+ * exists once cloudflared is up), and the bridge accepts that request only from
284
+ * loopback *with this token* — so a random local page cannot stop the bridge, and
285
+ * a second, unrelated DSH profile cannot either.
286
+ */
287
+ export function ensureAutostartToken(dataDir, options = {}) {
288
+ return ensureSecret(autostartTokenPath(dataDir), dataDir, options);
289
+ }
290
+
291
+ export function readAutostartToken(dataDir, { readFile = readFileSync } = {}) {
292
+ try {
293
+ return String(readFile(autostartTokenPath(dataDir), "utf8")).trim() || null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ function ensureSecret(target, dataDir, { readFile = readFileSync, writeFile = writeFileSync, mkdir = mkdirSync, random } = {}) {
300
+ try {
301
+ const existing = String(readFile(target, "utf8")).trim();
302
+ if (existing.length >= 16) return existing;
303
+ } catch {
304
+ // No usable secret on disk yet.
305
+ }
306
+ const secret = (random ?? (() => randomBytes(24).toString("base64url")))();
307
+ mkdir(dataDir, { recursive: true });
308
+ writeFile(target, secret, "utf8");
309
+ return secret;
310
+ }
311
+
312
+ export function tunnelStatePath(dataDir) {
313
+ return path.join(dataDir, "cloudflare-tunnel.json");
314
+ }
315
+
316
+ export function readTunnelState(dataDir, { readFile = readFileSync } = {}) {
317
+ try {
318
+ const parsed = JSON.parse(String(readFile(tunnelStatePath(dataDir), "utf8")));
319
+ if (!parsed || typeof parsed.url !== "string" || !Number.isInteger(parsed.pid)) return null;
320
+ return parsed;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ export function writeTunnelState(dataDir, state, { writeFile = writeFileSync, mkdir = mkdirSync, rename = renameSync } = {}) {
327
+ mkdir(dataDir, { recursive: true });
328
+ const target = tunnelStatePath(dataDir);
329
+ const temporary = `${target}.tmp`;
330
+ writeFile(temporary, JSON.stringify(state, null, 2), "utf8");
331
+ rename(temporary, target);
332
+ return state;
333
+ }
334
+
335
+ function isAlive(pid, { kill = process.kill.bind(process) } = {}) {
336
+ try {
337
+ kill(pid, 0);
338
+ return true;
339
+ } catch (error) {
340
+ // EPERM means "exists but not ours", which still counts as alive.
341
+ return error?.code === "EPERM";
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Start cloudflared and wait for the hostname it prints.
347
+ *
348
+ * The child is detached on purpose: the tunnel outliving DSH means the URL in an
349
+ * already paired phone keeps working after the desktop restarts, and it also
350
+ * avoids killing the tunnel whenever the Electron host decides to reload.
351
+ *
352
+ * Reuse is keyed on the state file plus a live pid plus a matching origin: a
353
+ * tunnel pointing at the old port would advertise a URL that resolves to a 502.
354
+ */
355
+ export async function startQuickTunnel({
356
+ command,
357
+ origin,
358
+ dataDir,
359
+ log = () => {},
360
+ timeoutMs = 40_000,
361
+ spawnImpl = spawn,
362
+ fetchImpl = globalThis.fetch,
363
+ kill = process.kill.bind(process),
364
+ } = {}) {
365
+ if (!command) throw new Error("cloudflared was not found");
366
+ if (!origin) throw new Error("the tunnel needs an origin to forward to");
367
+
368
+ const previous = readTunnelState(dataDir);
369
+ if (previous && previous.origin === origin && isAlive(previous.pid, { kill })) {
370
+ const reachable = await probeTunnel(previous.url, fetchImpl);
371
+ if (reachable) {
372
+ log(`reusing the running Cloudflare tunnel at ${previous.url}`);
373
+ return { ...previous, reused: true };
374
+ }
375
+ log(`the recorded tunnel is no longer answering; starting a new one`);
376
+ }
377
+
378
+ const child = spawnImpl(command, ["tunnel", "--no-autoupdate", "--url", origin], {
379
+ detached: true,
380
+ windowsHide: true,
381
+ stdio: ["ignore", "pipe", "pipe"],
382
+ });
383
+
384
+ const state = await new Promise((resolve, reject) => {
385
+ let output = "";
386
+ let settled = false;
387
+
388
+ const finish = (error, value) => {
389
+ if (settled) return;
390
+ settled = true;
391
+ clearTimeout(timer);
392
+ if (error) reject(error);
393
+ else resolve(value);
394
+ };
395
+
396
+ const timer = setTimeout(() => {
397
+ const tail = output.trim().split(/\r?\n/).slice(-6).join(" | ");
398
+ try {
399
+ kill(child.pid, "SIGKILL");
400
+ } catch {
401
+ // Already gone.
402
+ }
403
+ finish(new Error(`cloudflared did not announce a hostname within ${Math.round(timeoutMs / 1000)}s${tail ? `: ${tail}` : ""}`));
404
+ }, timeoutMs);
405
+ timer.unref?.();
406
+
407
+ const onData = (chunk) => {
408
+ output += String(chunk);
409
+ const url = extractTunnelUrl(output);
410
+ if (!url) return;
411
+ finish(null, {
412
+ url,
413
+ pid: child.pid,
414
+ origin,
415
+ startedAt: new Date().toISOString(),
416
+ });
417
+ };
418
+
419
+ child.stdout?.on?.("data", onData);
420
+ child.stderr?.on?.("data", onData);
421
+ child.once?.("error", (error) => finish(new Error(`cloudflared could not start: ${error.message}`)));
422
+ child.once?.("exit", (code) => {
423
+ const tail = output.trim().split(/\r?\n/).slice(-6).join(" | ");
424
+ finish(new Error(`cloudflared exited with code ${code} before announcing a hostname${tail ? `: ${tail}` : ""}`));
425
+ });
426
+ });
427
+
428
+ // The tunnel is meant to outlive this process; the pipes are only for the banner.
429
+ child.unref?.();
430
+ child.stdout?.destroy?.();
431
+ child.stderr?.destroy?.();
432
+ return writeTunnelState(dataDir, state) && { ...state, reused: false };
433
+ }
434
+
435
+ /**
436
+ * Anything but a transport error means the tunnel exists.
437
+ *
438
+ * A 502 is the normal answer while the local bridge is down, and it still proves
439
+ * Cloudflare's edge knows this hostname.
440
+ */
441
+ async function probeTunnel(url, fetchImpl = globalThis.fetch) {
442
+ try {
443
+ await fetchImpl(`${url}/api/health`, { signal: AbortSignal.timeout(6_000) });
444
+ return true;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }