@chenacc/agentbridge 0.1.2 → 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 +4 -6
- package/package.json +8 -2
- package/src/cli.js +129 -9
- package/src/config.js +4 -0
- package/src/server.js +45 -11
- package/src/tunnel.js +448 -0
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
|
|
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
|
-
|
|
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
|
+
"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
|
|
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
|
|
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
|
|
97
|
+
return null;
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
async function
|
|
101
|
-
|
|
102
|
-
|
|
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 <
|
|
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
|
-
|
|
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/config.js
CHANGED
|
@@ -170,6 +170,10 @@ export function loadConfig(env = process.env, workspace = process.cwd()) {
|
|
|
170
170
|
// recognise its own child in /api/health and restart it to change the public
|
|
171
171
|
// address, without ever touching a bridge the user started by hand.
|
|
172
172
|
autostart: env.AGENTBRIDGE_AUTOSTART === "1",
|
|
173
|
+
// Shared secret between the plugin that spawned this bridge and the bridge
|
|
174
|
+
// itself, so the plugin may ask for a restart to change the advertised address
|
|
175
|
+
// without handing that power to anything else that can reach loopback.
|
|
176
|
+
autostartToken: (env.AGENTBRIDGE_AUTOSTART_TOKEN ?? "").trim() || null,
|
|
173
177
|
// Outbound notifications: unset webhook means the feature is simply off.
|
|
174
178
|
notifyWebhook: pick(env, "AGENTBRIDGE_NOTIFY_WEBHOOK", notifyFile.webhook),
|
|
175
179
|
notifyFormat: pick(env, "AGENTBRIDGE_NOTIFY_FORMAT", notifyFile.format ?? "json").toLowerCase(),
|
package/src/server.js
CHANGED
|
@@ -386,6 +386,25 @@ export async function createBridge(options = {}) {
|
|
|
386
386
|
return shutdownPromise;
|
|
387
387
|
};
|
|
388
388
|
|
|
389
|
+
/**
|
|
390
|
+
* A restart requested by the plugin must not cut a running Agent turn in half.
|
|
391
|
+
*
|
|
392
|
+
* The plugin retries on the next desktop launch in that case; refusing is the
|
|
393
|
+
* safer answer than silently killing work the user is waiting on.
|
|
394
|
+
*/
|
|
395
|
+
const refuseWhenBusy = () => {
|
|
396
|
+
const active = sessions.list().filter((session) => ["starting", "running", "stopping"].includes(session.status));
|
|
397
|
+
if (!active.length) return;
|
|
398
|
+
throw Object.assign(new Error("仍有 Agent 任务正在运行,请先停止任务再关闭 Bridge"), {
|
|
399
|
+
statusCode: 409,
|
|
400
|
+
code: "ACTIVE_SESSIONS",
|
|
401
|
+
diagnostics: {
|
|
402
|
+
count: active.length,
|
|
403
|
+
sessions: active.map(({ id, title, status, adapterId }) => ({ id, title, status, adapterId })),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
|
|
389
408
|
const server = http.createServer(async (req, res) => {
|
|
390
409
|
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
391
410
|
try {
|
|
@@ -654,6 +673,31 @@ export async function createBridge(options = {}) {
|
|
|
654
673
|
// point remain authenticated (and gated by the access key when enabled).
|
|
655
674
|
if (req.method === "GET" && await serveStatic(res, url.pathname, url.search)) return;
|
|
656
675
|
|
|
676
|
+
// The DSH plugin starts this bridge itself and sometimes has to restart it,
|
|
677
|
+
// because the address it advertises (cloudflared hostname) only exists after
|
|
678
|
+
// the tunnel is up. Restarting needs a stop, and a stop needs an
|
|
679
|
+
// authorisation: loopback alone would let any local page POST here, so the
|
|
680
|
+
// caller must also present the token the plugin generated when it spawned
|
|
681
|
+
// this process. The token lives in the bridge's data directory, which keeps
|
|
682
|
+
// it out of the command line and out of the logs.
|
|
683
|
+
if (req.method === "POST" && url.pathname === "/api/local/autostart/shutdown") {
|
|
684
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
685
|
+
if (!config.autostartToken || !keyMatches(req.headers["x-agentbridge-autostart"], config.autostartToken)) {
|
|
686
|
+
throw Object.assign(new Error("Autostart token required"), { statusCode: 401 });
|
|
687
|
+
}
|
|
688
|
+
refuseWhenBusy();
|
|
689
|
+
shuttingDown = true;
|
|
690
|
+
json(res, 202, { accepted: true, message: "AgentBridge is restarting to change its public address" });
|
|
691
|
+
setImmediate(() => {
|
|
692
|
+
void shutdown().catch((error) => {
|
|
693
|
+
console.error("AgentBridge safe shutdown failed", error);
|
|
694
|
+
shuttingDown = false;
|
|
695
|
+
shutdownPromise = null;
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
657
701
|
requireAccessKey(req, url, config);
|
|
658
702
|
const device = pairing.authenticate(requestToken(req));
|
|
659
703
|
if (!device) throw Object.assign(new Error("Authentication required"), { statusCode: 401 });
|
|
@@ -794,17 +838,7 @@ export async function createBridge(options = {}) {
|
|
|
794
838
|
let match;
|
|
795
839
|
if (req.method === "POST" && url.pathname === "/api/local/shutdown") {
|
|
796
840
|
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
797
|
-
|
|
798
|
-
if (active.length) {
|
|
799
|
-
throw Object.assign(new Error("仍有 Agent 任务正在运行,请先停止任务再关闭 Bridge"), {
|
|
800
|
-
statusCode: 409,
|
|
801
|
-
code: "ACTIVE_SESSIONS",
|
|
802
|
-
diagnostics: {
|
|
803
|
-
count: active.length,
|
|
804
|
-
sessions: active.map(({ id, title, status, adapterId }) => ({ id, title, status, adapterId })),
|
|
805
|
-
},
|
|
806
|
-
});
|
|
807
|
-
}
|
|
841
|
+
refuseWhenBusy();
|
|
808
842
|
shuttingDown = true;
|
|
809
843
|
json(res, 202, { accepted: true, message: "AgentBridge 正在安全停止" });
|
|
810
844
|
setImmediate(() => {
|
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
|
+
}
|