@chenacc/agentbridge 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -145,6 +145,8 @@ scripts\start-agentbridge.ps1 -CloudflareTunnel # 3. 启动,窗口会打
145
145
 
146
146
  技能是纯文本文件,删掉对应的 `SKILL.md` 就等于卸载。
147
147
 
148
+ 二维码默认是**公网地址**(走 Cloudflare 隧道,手机用流量也能扫)。隧道要用的 `cloudflared` 缺了就自动下载到 `~/.agentbridge/bin`,和 DSH 插件共用同一套逻辑、同一个隧道和同一把访问密钥——DSH 那边已经开着隧道的话,CLI 直接复用,不会再起一条。只想在局域网用就设 `AGENTBRIDGE_NO_TUNNEL=1`;已经有自己的公网入口(命名隧道、Tailscale、ZeroTier)就设 `AGENTBRIDGE_PUBLIC_ORIGIN`。
149
+
148
150
  ### Claude Code
149
151
 
150
152
  ```powershell
@@ -178,13 +180,9 @@ node .\bin\agentbridge.js phone --agent codex --session <thread-id>
178
180
 
179
181
  **第 0 步:桥。** 插件是遥控器,桥是基站。从 0.2.0 起桥随插件一起安装,**DSH Desktop 启动时插件会自己在后台把它拉起来**,正常情况你什么都不用敲。
180
182
 
181
- 从 0.3.0 起,插件默认再开一条 **Cloudflare 隧道**,把桥映射成公网 https 域名,于是手机不必和电脑在同一个 Wi-Fi——扫描、出差、用流量都能开。这一步需要一个 Cloudflare 官方小工具:
182
-
183
- ```powershell
184
- winget install --id Cloudflare.cloudflared
185
- ```
183
+ 从 0.3.0 起,插件默认再开一条 **Cloudflare 隧道**,把桥映射成公网 https 域名,于是手机不必和电脑在同一个 Wi-Fi——扫码、出差、用流量都能开。隧道要用的 `cloudflared`(Cloudflare 官方客户端)同样是自动的:先找本机已有的那份,找不到就自己从官方 GitHub Releases 下到 `~/.agentbridge/bin`(约 55 MB,只下一次,不要管理员权限,下载后跑一次 `cloudflared --version` 验证才留下)。
186
184
 
187
- 没装也不影响使用,只是退回"手机与电脑同一 Wi-Fi"。想一直走局域网,设 `AGENTBRIDGE_NO_TUNNEL=1`;想用固定域名,去 Cloudflare 建命名隧道并把 `AGENTBRIDGE_PUBLIC_ORIGIN` 指向它。
185
+ 想自己装也行:`winget install --id Cloudflare.cloudflared`。下不动(断网、公司代理)照样能用,只是退回"手机与电脑同一 Wi-Fi"。想一直走局域网,设 `AGENTBRIDGE_NO_TUNNEL=1`;想用固定域名,去 Cloudflare 建命名隧道并把 `AGENTBRIDGE_PUBLIC_ORIGIN` 指向它。
188
186
 
189
187
  只有自动启动失败(沙箱限制、端口被占)才需要手动来一条:
190
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenacc/agentbridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Control local coding agents from a phone through a secure WeChat QR web console.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,12 @@
33
33
  "bin": {
34
34
  "agentbridge": "bin/agentbridge.js"
35
35
  },
36
+ "exports": {
37
+ ".": "./src/server.js",
38
+ "./server": "./src/server.js",
39
+ "./tunnel": "./src/tunnel.js",
40
+ "./package.json": "./package.json"
41
+ },
36
42
  "engines": {
37
43
  "node": ">=22"
38
44
  },
@@ -58,7 +64,7 @@
58
64
  "verify:responsive": "node scripts/verify-responsive.mjs",
59
65
  "verify:guide": "node scripts/verify-product-guide.mjs",
60
66
  "verify:release": "node scripts/verify-plugin-release.mjs",
61
- "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check integrations/dsh-phone/lib/tunnel.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs",
67
+ "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs",
62
68
  "verify:remote": "node scripts/verify-remote-tunnel.mjs"
63
69
  },
64
70
  "dependencies": {
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import QRCode from "qrcode";
8
+ import { ensureAccessKey, ensureCloudflared, findCloudflared, startQuickTunnel } from "./tunnel.js";
8
9
 
9
10
  const sourceDir = path.dirname(fileURLToPath(import.meta.url));
10
11
  const bridgeRoot = path.resolve(sourceDir, "..");
@@ -87,29 +88,143 @@ Examples:
87
88
  agentbridge install-claude-skill --user`;
88
89
  }
89
90
 
90
- async function bridgeIsReady(origin) {
91
+ async function bridgeHealth(origin) {
91
92
  try {
92
93
  const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(700) });
93
94
  const body = response.ok ? await response.json() : null;
94
- return body?.ok === true;
95
+ return body?.ok === true ? body : null;
95
96
  } catch {
96
- return false;
97
+ return null;
97
98
  }
98
99
  }
99
100
 
100
- async function ensureBridge(origin, autoStart) {
101
- if (await bridgeIsReady(origin)) return;
102
- if (!autoStart) throw new Error(`Bridge is not running at ${origin}`);
101
+ async function bridgeIsReady(origin) {
102
+ return (await bridgeHealth(origin)) !== null;
103
+ }
104
+
105
+ /** Whether a host:port is only reachable from the same network. */
106
+ export function isPrivateHost(hostname) {
107
+ const host = String(hostname ?? "").replace(/^\[|\]$/g, "").toLowerCase();
108
+ if (!host) return true;
109
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local")) return true;
110
+ if (/^10\./.test(host)) return true;
111
+ if (/^192\.168\./.test(host)) return true;
112
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
113
+ // Tailscale and ZeroTier hand out addresses from these ranges, which are
114
+ // reachable from the phone only when it is on the same overlay.
115
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host)) return true;
116
+ return false;
117
+ }
118
+
119
+ export function describeOrigin(origin) {
120
+ try {
121
+ const url = new URL(origin);
122
+ return isPrivateHost(url.hostname) ? "private" : "public";
123
+ } catch {
124
+ return "private";
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Where the tunnel state and the access key live.
130
+ *
131
+ * Deliberately the same directory the DSH plugin uses: if that plugin already
132
+ * brought a tunnel up to the same port, the CLI reuses it instead of opening a
133
+ * second one, and both sides read the same access key.
134
+ */
135
+ function bridgeDataDir() {
136
+ return path.resolve(process.env.AGENTBRIDGE_DATA_DIR ?? path.join(os.homedir(), ".agentbridge"));
137
+ }
138
+
139
+ /**
140
+ * The public address the QR code should carry, if this run is going to have one.
141
+ *
142
+ * Returns null when the machine should stay on the LAN: the user opted out, the
143
+ * bridge is already running under someone else's address, or no tunnel could be
144
+ * brought up. Every one of those is a normal outcome — same-Wi-Fi pairing still
145
+ * works — so the reason is printed instead of thrown.
146
+ */
147
+ export async function resolvePublicOrigin({ health, log = () => {}, env = process.env, dataDir = bridgeDataDir(), timeoutMs = 40_000 } = {}) {
148
+ const explicit = String(env.AGENTBRIDGE_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
149
+ if (explicit) return { origin: explicit, accessKey: null, how: "AGENTBRIDGE_PUBLIC_ORIGIN" };
150
+
151
+ const supplied = String(env.AGENTBRIDGE_TUNNEL_URL ?? "").trim().replace(/\/$/, "");
152
+ if (supplied) return { origin: supplied, accessKey: ensureAccessKey(dataDir), how: "AGENTBRIDGE_TUNNEL_URL" };
153
+
154
+ if (env.AGENTBRIDGE_NO_TUNNEL === "1") {
155
+ log("AGENTBRIDGE_NO_TUNNEL=1: the QR code will only work on this Wi-Fi");
156
+ return null;
157
+ }
158
+ // An already-running bridge owns its address; asking it to move would mean
159
+ // restarting a service the user may be using right now.
160
+ if (health) {
161
+ const advertised = String(health.advertisedOrigin ?? "");
162
+ if (describeOrigin(advertised) === "public") return { origin: advertised, accessKey: null, how: "already running" };
163
+ log(`AgentBridge 已经在以局域网地址运行(${advertised || "unknown"}),本次二维码沿用这个地址。想换公网地址:先停掉它再重跑。`);
164
+ return null;
165
+ }
166
+
167
+ let command = findCloudflared({ env, home: os.homedir() });
168
+ if (!command) {
169
+ log("没有找到 cloudflared,正在从 Cloudflare 官方 Releases 下载(只下这一次)…");
170
+ command = await ensureCloudflared({ dataDir, env, log });
171
+ }
172
+ if (!command) {
173
+ log("仍然没有 cloudflared,二维码只能用在这台电脑所在 Wi-Fi 里。");
174
+ return null;
175
+ }
176
+
177
+ const port = new URL(`http://127.0.0.1:${env.AGENTBRIDGE_PORT ?? "8787"}`).port;
178
+ try {
179
+ const tunnel = await startQuickTunnel({
180
+ command,
181
+ origin: `http://127.0.0.1:${port}`,
182
+ dataDir,
183
+ log,
184
+ timeoutMs,
185
+ });
186
+ return { origin: tunnel.url, accessKey: ensureAccessKey(dataDir), how: tunnel.reused ? "cloudflare(复用已有隧道)" : "cloudflare" };
187
+ } catch (error) {
188
+ log(`Cloudflare 隧道没起来,二维码只能用在这台电脑所在 Wi-Fi 里:${error instanceof Error ? error.message : String(error)}`);
189
+ return null;
190
+ }
191
+ }
192
+
193
+ async function startBridge(origin, env = {}) {
103
194
  const child = spawn(process.execPath, [path.join(bridgeRoot, "src", "server.js")], {
104
195
  cwd: bridgeRoot,
105
196
  detached: true,
106
197
  stdio: "ignore",
107
198
  windowsHide: true,
199
+ env: { ...process.env, ...env },
108
200
  });
109
201
  child.unref();
110
- for (let attempt = 0; attempt < 30; attempt += 1) {
202
+ for (let attempt = 0; attempt < 60; attempt += 1) {
111
203
  await new Promise((resolve) => setTimeout(resolve, 100));
112
- if (await bridgeIsReady(origin)) return;
204
+ if (await bridgeIsReady(origin)) return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ async function ensureBridge(origin, autoStart, log = () => {}) {
210
+ const health = await bridgeHealth(origin);
211
+ if (health) return health;
212
+ if (!autoStart) throw new Error(`Bridge is not running at ${origin}`);
213
+
214
+ // The tunnel has to exist before the bridge starts, because the address is
215
+ // baked into the pairing URL at startup — the exact opposite of the DSH
216
+ // plugin, which keeps a local bridge up and restarts it once a tunnel exists.
217
+ const remote = await resolvePublicOrigin({ log });
218
+ const env = remote?.accessKey
219
+ ? { AGENTBRIDGE_PUBLIC_ORIGIN: remote.origin, AGENTBRIDGE_ACCESS_KEY: remote.accessKey }
220
+ : {};
221
+ if (await startBridge(origin, env)) {
222
+ if (remote) log(`公网地址就绪:${remote.origin}(${remote.how})`);
223
+ return bridgeHealth(origin);
224
+ }
225
+ if (Object.keys(env).length) {
226
+ log("带公网地址的桥没起来,退回只用这台电脑所在 Wi-Fi。");
227
+ if (await startBridge(origin)) return bridgeHealth(origin);
113
228
  }
114
229
  throw new Error(`Could not start Bridge at ${origin}`);
115
230
  }
@@ -117,7 +232,8 @@ async function ensureBridge(origin, autoStart) {
117
232
  async function requestPhone(options) {
118
233
  const port = process.env.AGENTBRIDGE_PORT ?? "8787";
119
234
  const origin = (process.env.AGENTBRIDGE_LOCAL_ORIGIN ?? `http://127.0.0.1:${port}`).replace(/\/$/, "");
120
- await ensureBridge(origin, !options["no-start"]);
235
+ const log = (message) => console.error(`[agentbridge] ${message}`);
236
+ const health = await ensureBridge(origin, !options["no-start"], log);
121
237
  const cwd = path.resolve(options.cwd ?? process.cwd());
122
238
  const adapterId = options.agent ?? "claude";
123
239
  const response = await fetch(`${origin}/api/local/phone`, {
@@ -139,10 +255,14 @@ async function requestPhone(options) {
139
255
  return result;
140
256
  }
141
257
  const qr = await QRCode.toString(result.pairing.url, { type: "terminal", small: true, errorCorrectionLevel: "M" });
258
+ const network = describeOrigin(result.pairing.url) === "public"
259
+ ? "公网地址,手机用流量也能打开"
260
+ : "局域网地址,手机需与电脑连同一个 Wi-Fi";
142
261
  console.log(`\nAgentBridge 已绑定当前对话`);
143
262
  console.log(`Agent: ${adapterId}`);
144
263
  console.log(`项目: ${cwd}`);
145
264
  console.log(`会话: ${result.session.providerSessionId ?? result.session.id}`);
265
+ console.log(`网络: ${network}`);
146
266
  console.log(`有效期: ${new Date(result.pairing.expiresAt).toLocaleTimeString()}(一次性)\n`);
147
267
  console.log(qr);
148
268
  if (result.qrImageUrl) {
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
+ }