@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/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 { parseMode, resolveRemote } from "./remote.js";
8
9
 
9
10
  const sourceDir = path.dirname(fileURLToPath(import.meta.url));
10
11
  const bridgeRoot = path.resolve(sourceDir, "..");
@@ -87,29 +88,118 @@ 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 address the QR code should carry, if this run is going to have one.
141
+ *
142
+ * The path itself is chosen by `src/remote.js`, which the DSH plugin shares — so
143
+ * "which network am I on" has one answer in both entry points. Returns null when
144
+ * the machine should stay on the LAN: the user opted out, the bridge is already
145
+ * running under someone else's address, or no path could be brought up. Every one
146
+ * of those is a normal outcome — same-Wi-Fi pairing still works — so the reason is
147
+ * logged instead of thrown.
148
+ */
149
+ export async function resolvePublicOrigin({ health, log = () => {}, env = process.env, dataDir = bridgeDataDir(), timeoutMs = 40_000 } = {}) {
150
+ // An already-running bridge owns its address; asking it to move would mean
151
+ // restarting a service the user may be using right now.
152
+ if (health && !env.AGENTBRIDGE_PUBLIC_ORIGIN && !env.AGENTBRIDGE_TUNNEL_URL && parseMode(env) !== "lan") {
153
+ const advertised = String(health.advertisedOrigin ?? "");
154
+ if (describeOrigin(advertised) === "public") return { origin: advertised, accessKey: null, provider: "already-running", label: "已运行的桥", how: "already running" };
155
+ log(`AgentBridge 已经在以局域网地址运行(${advertised || "unknown"}),本次二维码沿用这个地址。想换公网地址:先停掉它再重跑。`);
156
+ return null;
157
+ }
158
+
159
+ const decided = await resolveRemote({ env, dataDir, log, port: String(env.AGENTBRIDGE_PORT ?? "8787") });
160
+ if (!decided) return null;
161
+ return { ...decided, how: decided.detail ?? decided.provider };
162
+ }
163
+
164
+ async function startBridge(origin, env = {}) {
103
165
  const child = spawn(process.execPath, [path.join(bridgeRoot, "src", "server.js")], {
104
166
  cwd: bridgeRoot,
105
167
  detached: true,
106
168
  stdio: "ignore",
107
169
  windowsHide: true,
170
+ env: { ...process.env, ...env },
108
171
  });
109
172
  child.unref();
110
- for (let attempt = 0; attempt < 30; attempt += 1) {
173
+ for (let attempt = 0; attempt < 60; attempt += 1) {
111
174
  await new Promise((resolve) => setTimeout(resolve, 100));
112
- if (await bridgeIsReady(origin)) return;
175
+ if (await bridgeIsReady(origin)) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ async function ensureBridge(origin, autoStart, log = () => {}) {
181
+ const health = await bridgeHealth(origin);
182
+ if (health) return health;
183
+ if (!autoStart) throw new Error(`Bridge is not running at ${origin}`);
184
+
185
+ // The path has to be chosen before the bridge starts, because the address is
186
+ // baked into the pairing URL at startup — the exact opposite of the DSH plugin,
187
+ // which keeps a local bridge up and restarts it once the address changes.
188
+ const remote = await resolvePublicOrigin({ log });
189
+ const env = remote
190
+ ? {
191
+ AGENTBRIDGE_PUBLIC_ORIGIN: remote.origin,
192
+ AGENTBRIDGE_REMOTE_PROVIDER: remote.provider ?? "remote",
193
+ ...(remote.accessKey ? { AGENTBRIDGE_ACCESS_KEY: remote.accessKey } : {}),
194
+ }
195
+ : {};
196
+ if (await startBridge(origin, env)) {
197
+ if (remote) log(`公网地址就绪:${remote.origin}(${remote.how})`);
198
+ return bridgeHealth(origin);
199
+ }
200
+ if (Object.keys(env).length) {
201
+ log("带公网地址的桥没起来,退回只用这台电脑所在 Wi-Fi。");
202
+ if (await startBridge(origin)) return bridgeHealth(origin);
113
203
  }
114
204
  throw new Error(`Could not start Bridge at ${origin}`);
115
205
  }
@@ -117,7 +207,8 @@ async function ensureBridge(origin, autoStart) {
117
207
  async function requestPhone(options) {
118
208
  const port = process.env.AGENTBRIDGE_PORT ?? "8787";
119
209
  const origin = (process.env.AGENTBRIDGE_LOCAL_ORIGIN ?? `http://127.0.0.1:${port}`).replace(/\/$/, "");
120
- await ensureBridge(origin, !options["no-start"]);
210
+ const log = (message) => console.error(`[agentbridge] ${message}`);
211
+ const health = await ensureBridge(origin, !options["no-start"], log);
121
212
  const cwd = path.resolve(options.cwd ?? process.cwd());
122
213
  const adapterId = options.agent ?? "claude";
123
214
  const response = await fetch(`${origin}/api/local/phone`, {
@@ -139,10 +230,18 @@ async function requestPhone(options) {
139
230
  return result;
140
231
  }
141
232
  const qr = await QRCode.toString(result.pairing.url, { type: "terminal", small: true, errorCorrectionLevel: "M" });
233
+ // Naming the path matters: "it worked yesterday" usually means a different
234
+ // provider won this time, or none did. The bridge reports which one it was
235
+ // started with, so this stays right even when the tunnel is reused.
236
+ const providerLabel = health?.remoteProvider ?? null;
237
+ const network = describeOrigin(result.pairing.url) === "public"
238
+ ? `公网地址,手机用流量也能打开${providerLabel ? `(${providerLabel})` : ""}`
239
+ : "局域网地址,手机需与电脑连同一个 Wi-Fi";
142
240
  console.log(`\nAgentBridge 已绑定当前对话`);
143
241
  console.log(`Agent: ${adapterId}`);
144
242
  console.log(`项目: ${cwd}`);
145
243
  console.log(`会话: ${result.session.providerSessionId ?? result.session.id}`);
244
+ console.log(`网络: ${network}`);
146
245
  console.log(`有效期: ${new Date(result.pairing.expiresAt).toLocaleTimeString()}(一次性)\n`);
147
246
  console.log(qr);
148
247
  if (result.qrImageUrl) {
package/src/config.js CHANGED
@@ -174,6 +174,10 @@ export function loadConfig(env = process.env, workspace = process.cwd()) {
174
174
  // itself, so the plugin may ask for a restart to change the advertised address
175
175
  // without handing that power to anything else that can reach loopback.
176
176
  autostartToken: (env.AGENTBRIDGE_AUTOSTART_TOKEN ?? "").trim() || null,
177
+ // Which network path produced advertisedOrigin. Informational only: the
178
+ // launcher sets it so /api/health and the startup log can name the path
179
+ // instead of leaving the user to guess why the address changed.
180
+ remoteProvider: (env.AGENTBRIDGE_REMOTE_PROVIDER ?? "").trim() || null,
177
181
  // Outbound notifications: unset webhook means the feature is simply off.
178
182
  notifyWebhook: pick(env, "AGENTBRIDGE_NOTIFY_WEBHOOK", notifyFile.webhook),
179
183
  notifyFormat: pick(env, "AGENTBRIDGE_NOTIFY_FORMAT", notifyFile.format ?? "json").toLowerCase(),
package/src/remote.js ADDED
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Which network path a QR code should point at.
3
+ *
4
+ * There is more than one way to put a local bridge in front of a phone, and they
5
+ * differ in what they cost the user: a Cloudflare quick tunnel needs nothing on
6
+ * the phone but hands out a random hostname; Tailscale Funnel is a fixed hostname
7
+ * and still needs nothing on the phone; Tailscale and ZeroTier private addresses
8
+ * are the safest but require an app on the phone and a login.
9
+ *
10
+ * So the choice is data, not `if` statements scattered across the CLI and the DSH
11
+ * plugin: each path is a provider with the same shape, and one resolver walks them
12
+ * in a documented order. Everything that touches the machine (running `tailscale`,
13
+ * reading adapters, downloading cloudflared) is injectable, because this module
14
+ * decides whether a public address exists at all.
15
+ */
16
+ import os from "node:os";
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import {
21
+ ensureAccessKey,
22
+ ensureCloudflared,
23
+ findCloudflared,
24
+ readTunnelState,
25
+ startQuickTunnel,
26
+ tunnelStatePath,
27
+ } from "./tunnel.js";
28
+
29
+ /** Run one command and collect its output instead of streaming it. */
30
+ export function runCommand(command, args, { timeoutMs = 15_000, env = process.env } = {}) {
31
+ return new Promise((resolve) => {
32
+ let child;
33
+ try {
34
+ child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env });
35
+ } catch (error) {
36
+ return resolve({ code: null, stdout: "", stderr: String(error?.message ?? error), missing: true });
37
+ }
38
+ let stdout = "";
39
+ let stderr = "";
40
+ let settled = false;
41
+ const finish = (result) => {
42
+ if (settled) return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ resolve(result);
46
+ };
47
+ const timer = setTimeout(() => {
48
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
49
+ finish({ code: null, stdout, stderr, timedOut: true });
50
+ }, timeoutMs);
51
+ timer.unref?.();
52
+ child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
53
+ child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
54
+ child.once?.("error", (error) => finish({ code: null, stdout, stderr, stderr: String(error?.message ?? error), missing: true }));
55
+ child.once?.("exit", (code) => finish({ code, stdout, stderr }));
56
+ });
57
+ }
58
+
59
+ function parseJson(text) {
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Tailscale: shared detection for the private-address and Funnel providers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function tailscaleCandidates(env = process.env) {
72
+ const candidates = [];
73
+ if (env.AGENTBRIDGE_TAILSCALE) candidates.push(env.AGENTBRIDGE_TAILSCALE);
74
+ if (env.ProgramFiles) candidates.push(path.join(env.ProgramFiles, "Tailscale", "tailscale.exe"));
75
+ if (env["ProgramFiles(x86)"]) candidates.push(path.join(env["ProgramFiles(x86)"], "Tailscale", "tailscale.exe"));
76
+ candidates.push("/usr/bin/tailscale", "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale");
77
+ candidates.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
78
+ return candidates;
79
+ }
80
+
81
+ export function findTailscale({ env = process.env, exists = existsSync } = {}) {
82
+ if (env.AGENTBRIDGE_TAILSCALE) return exists(env.AGENTBRIDGE_TAILSCALE) ? env.AGENTBRIDGE_TAILSCALE : null;
83
+ for (const candidate of tailscaleCandidates(env)) {
84
+ // A bare command name is resolved by the shell, not the filesystem.
85
+ if (!path.isAbsolute(candidate)) continue;
86
+ if (exists(candidate)) return candidate;
87
+ }
88
+ return "tailscale";
89
+ }
90
+
91
+ /** The 100.64/10 address Tailscale assigned this node, if it is up. */
92
+ export function tailscaleIpv4(status) {
93
+ const addresses = Array.isArray(status?.Self?.TailscaleIPs) ? status.Self.TailscaleIPs : [];
94
+ return addresses.find((address) => /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(String(address))) ?? null;
95
+ }
96
+
97
+ export function tailscaleDnsName(status) {
98
+ const name = String(status?.Self?.DNSName ?? "").replace(/\.$/, "");
99
+ return name || null;
100
+ }
101
+
102
+ /**
103
+ * Ask Tailscale what it is doing.
104
+ *
105
+ * `BackendState` is the interesting field: "Running" means the node is up and on
106
+ * a tailnet. "NeedsLogin" and "Stopped" are the two states users actually hit,
107
+ * and they deserve different advice, so they are reported separately rather than
108
+ * collapsing into "not available".
109
+ */
110
+ export async function detectTailscale({ env = process.env, run = runCommand, exists = existsSync } = {}) {
111
+ const bin = findTailscale({ env, exists });
112
+ if (!bin) return { available: false, reason: "Tailscale is not installed", bin: null };
113
+ const result = await run(bin, ["status", "--json"]);
114
+ if (result.missing) return { available: false, reason: "Tailscale is not installed", bin };
115
+ if (result.code !== 0) {
116
+ const detail = `${result.stderr || result.stdout}`.trim();
117
+ const needsOperator = /access denied|permission|operator/i.test(detail);
118
+ return {
119
+ available: false,
120
+ bin,
121
+ reason: needsOperator
122
+ ? "this Windows user cannot read Tailscale status; run `tailscale set --operator=%USERNAME%` once, or start DSH as administrator"
123
+ : `tailscale status failed: ${detail || `exit ${result.code}`}`,
124
+ };
125
+ }
126
+ const status = parseJson(result.stdout);
127
+ if (!status) return { available: false, bin, reason: "Tailscale returned unreadable status" };
128
+ const state = String(status.BackendState ?? "");
129
+ if (state !== "Running") {
130
+ return {
131
+ available: false,
132
+ bin,
133
+ status,
134
+ reason: state === "NeedsLogin"
135
+ ? "Tailscale is installed but not signed in; open its tray app and log in"
136
+ : `Tailscale is not running (${state || "unknown"})`,
137
+ };
138
+ }
139
+ return { available: true, bin, status, ipv4: tailscaleIpv4(status), dnsName: tailscaleDnsName(status) };
140
+ }
141
+
142
+ function tailscaleProvider({ id, label, isPublic }) {
143
+ return {
144
+ id,
145
+ label,
146
+ isPublic,
147
+ async probe(ctx) {
148
+ const found = await detectTailscale({ env: ctx.env, run: ctx.run, exists: ctx.exists });
149
+ return found.available ? { ready: true, detail: found } : { ready: false, reason: found.reason, detail: found };
150
+ },
151
+ async ensure(ctx, probed) {
152
+ const found = probed.detail;
153
+ if (!found?.ipv4) {
154
+ throw new Error("Tailscale is running but has no 100.64/10 address yet");
155
+ }
156
+ return { origin: `http://${found.ipv4}:${ctx.port}`, detail: `Tailscale ${found.ipv4}` };
157
+ },
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Tailscale Funnel: fixed public hostname, nothing to install on the phone
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export const funnelProvider = {
166
+ id: "funnel",
167
+ label: "Tailscale Funnel(固定域名)",
168
+ isPublic: true,
169
+ async probe(ctx) {
170
+ const found = await detectTailscale({ env: ctx.env, run: ctx.run, exists: ctx.exists });
171
+ if (!found.available) return { ready: false, reason: found.reason };
172
+ if (!found.dnsName) return { ready: false, reason: "Tailscale has not assigned this node a DNS name yet" };
173
+ const status = await ctx.run(found.bin, ["funnel", "status"]);
174
+ const text = `${status.stdout}${status.stderr}`;
175
+ // `funnel status` prints "No funnel configuration" when nothing is exposed.
176
+ const enabled = status.code === 0 && !/no funnel|not enabled|no configuration/i.test(text);
177
+ if (enabled) return { ready: true, detail: found };
178
+ if (!ctx.allowEnable) {
179
+ return { ready: false, reason: "Funnel is not enabled for this node (set AGENTBRIDGE_REMOTE=funnel to turn it on)" };
180
+ }
181
+ return { ready: true, needsEnable: true, detail: found };
182
+ },
183
+ async ensure(ctx, probed) {
184
+ const found = probed.detail;
185
+ if (probed.needsEnable) {
186
+ const start = await ctx.run(found.bin, ["funnel", "--bg", "--https=443", `http://127.0.0.1:${ctx.port}`]);
187
+ if (start.code !== 0) {
188
+ const text = `${start.stderr}${start.stdout}`.trim();
189
+ const hint = /not enabled|Funnel is not|enable/i.test(text)
190
+ ? " — enable Funnel for this tailnet in the Tailscale admin console (Access controls → Funnel)"
191
+ : "";
192
+ throw new Error(`tailscale funnel failed: ${text || `exit ${start.code}`}${hint}`);
193
+ }
194
+ }
195
+ return { origin: `https://${found.dnsName}`, detail: `Tailscale Funnel ${found.dnsName}` };
196
+ },
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // ZeroTier: private address, app required on the phone
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export function zerotierAddress(interfaces = os.networkInterfaces()) {
204
+ for (const [name, entries] of Object.entries(interfaces)) {
205
+ if (!/zerotier/i.test(name)) continue;
206
+ for (const entry of entries ?? []) {
207
+ if ((entry.family !== "IPv4" && entry.family !== 4) || entry.internal) continue;
208
+ if (!/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(entry.address)) continue;
209
+ return { address: entry.address, name };
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ export const zerotierProvider = {
216
+ id: "zerotier",
217
+ label: "ZeroTier(私有网络)",
218
+ isPublic: false,
219
+ async probe(ctx) {
220
+ const override = String(ctx.env.AGENTBRIDGE_ZEROTIER ?? "").trim();
221
+ if (override) {
222
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(override)) {
223
+ return { ready: false, reason: `AGENTBRIDGE_ZEROTIER is not an IPv4 address: ${override}` };
224
+ }
225
+ return { ready: true, detail: { address: override, name: "AGENTBRIDGE_ZEROTIER" } };
226
+ }
227
+ const found = zerotierAddress(ctx.interfaces ?? os.networkInterfaces());
228
+ if (!found) {
229
+ return { ready: false, reason: "no ZeroTier adapter with a managed IPv4 address (join a network and authorize this member)" };
230
+ }
231
+ return { ready: true, detail: found };
232
+ },
233
+ async ensure(ctx, probed) {
234
+ return { origin: `http://${probed.detail.address}:${ctx.port}`, detail: `ZeroTier ${probed.detail.address}` };
235
+ },
236
+ };
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Cloudflare: quick tunnel (random hostname) and named tunnel (your domain)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ export const cloudflareProvider = {
243
+ id: "cloudflare",
244
+ label: "Cloudflare quick tunnel(自动)",
245
+ isPublic: true,
246
+ async probe(ctx) {
247
+ const command = findCloudflared({ env: ctx.env, home: ctx.home, exists: ctx.exists });
248
+ if (command) return { ready: true, detail: command };
249
+ if (ctx.env.AGENTBRIDGE_NO_DOWNLOAD === "1") {
250
+ return { ready: false, reason: "cloudflared is missing and AGENTBRIDGE_NO_DOWNLOAD=1 forbids fetching it" };
251
+ }
252
+ const downloaded = await ensureCloudflared({ dataDir: ctx.dataDir, env: ctx.env, log: ctx.log });
253
+ if (!downloaded) return { ready: false, reason: "cloudflared is missing and could not be downloaded" };
254
+ return { ready: true, detail: downloaded };
255
+ },
256
+ async ensure(ctx, probed) {
257
+ const tunnel = await startQuickTunnel({
258
+ command: probed.detail,
259
+ origin: `http://127.0.0.1:${ctx.port}`,
260
+ dataDir: ctx.dataDir,
261
+ log: ctx.log,
262
+ });
263
+ return { origin: tunnel.url, detail: tunnel.reused ? "Cloudflare quick tunnel (reused)" : "Cloudflare quick tunnel" };
264
+ },
265
+ };
266
+
267
+ function namedTunnelStatePath(dataDir) {
268
+ return path.join(dataDir, "cloudflare-named-tunnel.json");
269
+ }
270
+
271
+ export const namedTunnelProvider = {
272
+ id: "named",
273
+ label: "Cloudflare 命名隧道(固定域名)",
274
+ isPublic: true,
275
+ async probe(ctx) {
276
+ const name = String(ctx.env.AGENTBRIDGE_CLOUDFLARE_TUNNEL ?? "").trim();
277
+ const hostname = String(ctx.env.AGENTBRIDGE_CLOUDFLARE_HOSTNAME ?? "").trim();
278
+ if (!name) return { ready: false, reason: "AGENTBRIDGE_CLOUDFLARE_TUNNEL is not set" };
279
+ if (!hostname) return { ready: false, reason: "AGENTBRIDGE_CLOUDFLARE_TUNNEL needs AGENTBRIDGE_CLOUDFLARE_HOSTNAME" };
280
+ const command = findCloudflared({ env: ctx.env, home: ctx.home, exists: ctx.exists });
281
+ if (!command) return { ready: false, reason: "cloudflared is missing" };
282
+ return { ready: true, detail: { command, name, origin: `https://${hostname.replace(/^https?:\/\//, "").replace(/\/$/, "")}` } };
283
+ },
284
+ async ensure(ctx, probed) {
285
+ const { command, name, origin } = probed.detail;
286
+ const statePath = namedTunnelStatePath(ctx.dataDir);
287
+ let previous = null;
288
+ try {
289
+ previous = JSON.parse(readFileSync(statePath, "utf8"));
290
+ } catch {
291
+ previous = null;
292
+ }
293
+ const alive = previous && Number.isInteger(previous.pid) && (() => {
294
+ try {
295
+ process.kill(previous.pid, 0);
296
+ return true;
297
+ } catch (error) {
298
+ return error?.code === "EPERM";
299
+ }
300
+ })();
301
+ if (alive && previous.tunnel === name) return { origin, detail: `Cloudflare named tunnel ${name} (reused)` };
302
+
303
+ const child = spawn(command, ["tunnel", "--no-autoupdate", "run", name], {
304
+ detached: true,
305
+ windowsHide: true,
306
+ stdio: "ignore",
307
+ });
308
+ child.unref();
309
+ if (child.pid) {
310
+ try {
311
+ writeFileSync(statePath, JSON.stringify({ pid: child.pid, tunnel: name, origin, startedAt: new Date().toISOString() }, null, 2), "utf8");
312
+ } catch {
313
+ // The record only saves a respawn; the tunnel works either way.
314
+ }
315
+ }
316
+ // cloudflared needs a moment before the edge can route the hostname, and the
317
+ // bridge is about to be started with this origin baked in.
318
+ for (let attempt = 0; attempt < 20; attempt += 1) {
319
+ await new Promise((resolve) => setTimeout(resolve, 500));
320
+ try {
321
+ await ctx.fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(5_000) });
322
+ return { origin, detail: `Cloudflare named tunnel ${name}` };
323
+ } catch {
324
+ // Edge not routing yet, or the bridge is not up — both are expected now.
325
+ }
326
+ }
327
+ return { origin, detail: `Cloudflare named tunnel ${name}` };
328
+ },
329
+ };
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // The resolver
333
+ // ---------------------------------------------------------------------------
334
+
335
+ export const PROVIDER_IDS = ["named", "funnel", "cloudflare", "tailscale", "zerotier"];
336
+
337
+ const PROVIDERS = {
338
+ named: namedTunnelProvider,
339
+ funnel: funnelProvider,
340
+ cloudflare: cloudflareProvider,
341
+ tailscale: tailscaleProvider({ id: "tailscale", label: "Tailscale(私有网络)", isPublic: false }),
342
+ zerotier: zerotierProvider,
343
+ };
344
+
345
+ /**
346
+ * `auto` is deliberately not "try the fanciest thing first".
347
+ *
348
+ * A named tunnel is only in the list when the user configured one, and Funnel is
349
+ * only *reused* in auto mode — turning it on changes the user's tailnet, so that
350
+ * has to be an explicit choice (`AGENTBRIDGE_REMOTE=funnel`). The quick tunnel
351
+ * comes next because it needs nothing from anyone, and the private overlays come
352
+ * last: they are safer, but they require an app on the phone, which is exactly
353
+ * what most people are trying to avoid.
354
+ */
355
+ export const AUTO_ORDER = ["named", "funnel", "cloudflare", "tailscale", "zerotier"];
356
+
357
+ export function parseMode(env = process.env) {
358
+ const raw = String(env.AGENTBRIDGE_REMOTE ?? "").trim().toLowerCase();
359
+ if (!raw || raw === "auto") return "auto";
360
+ if (raw === "lan" || raw === "local" || raw === "off" || raw === "none") return "lan";
361
+ return PROVIDER_IDS.includes(raw) ? raw : "auto";
362
+ }
363
+
364
+ /**
365
+ * The public or overlay address the QR code should carry, or null for the LAN.
366
+ *
367
+ * Returns the provider that won, because "which path am I on" is the first thing
368
+ * anyone asks when the QR code does not open.
369
+ */
370
+ export async function resolveRemote({
371
+ env = process.env,
372
+ dataDir,
373
+ port = String(env.AGENTBRIDGE_PORT ?? "8787"),
374
+ log = () => {},
375
+ mode = parseMode(env),
376
+ providers = PROVIDERS,
377
+ run = runCommand,
378
+ fetchImpl = globalThis.fetch,
379
+ exists = existsSync,
380
+ home = os.homedir(),
381
+ interfaces,
382
+ } = {}) {
383
+ const explicit = String(env.AGENTBRIDGE_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
384
+ if (explicit) {
385
+ return { provider: "explicit", label: "AGENTBRIDGE_PUBLIC_ORIGIN", origin: explicit, accessKey: null, isPublic: !/^http:\/\/(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(explicit) };
386
+ }
387
+ const supplied = String(env.AGENTBRIDGE_TUNNEL_URL ?? "").trim().replace(/\/$/, "");
388
+ if (supplied) {
389
+ return { provider: "tunnel-url", label: "AGENTBRIDGE_TUNNEL_URL", origin: supplied, accessKey: ensureAccessKey(dataDir), isPublic: true };
390
+ }
391
+ if (mode === "lan" || env.AGENTBRIDGE_NO_TUNNEL === "1") {
392
+ const why = env.AGENTBRIDGE_NO_TUNNEL === "1" ? "AGENTBRIDGE_NO_TUNNEL=1" : `AGENTBRIDGE_REMOTE=${mode}`;
393
+ log(`${why}: the QR code will work on this Wi-Fi and nowhere else`);
394
+ return null;
395
+ }
396
+
397
+ const order = mode === "auto" ? AUTO_ORDER : [mode];
398
+ const reasons = [];
399
+ for (const id of order) {
400
+ const provider = providers[id];
401
+ if (!provider) continue;
402
+ const ctx = { env, dataDir, port, log, run, fetch: fetchImpl, exists, home, interfaces, allowEnable: mode === id };
403
+ let probed;
404
+ try {
405
+ probed = await provider.probe(ctx);
406
+ } catch (error) {
407
+ reasons.push(`${id}: ${error?.message ?? error}`);
408
+ continue;
409
+ }
410
+ if (!probed?.ready) {
411
+ reasons.push(`${id}: ${probed?.reason ?? "not available"}`);
412
+ continue;
413
+ }
414
+ try {
415
+ const result = await provider.ensure(ctx, probed);
416
+ log(`${provider.label} → ${result.origin}`);
417
+ return {
418
+ provider: id,
419
+ label: provider.label,
420
+ origin: result.origin,
421
+ detail: result.detail,
422
+ // Anything reachable from the internet gets the key gate, no exceptions.
423
+ accessKey: provider.isPublic ? ensureAccessKey(dataDir) : null,
424
+ isPublic: provider.isPublic,
425
+ };
426
+ } catch (error) {
427
+ reasons.push(`${id}: ${error?.message ?? error}`);
428
+ continue;
429
+ }
430
+ }
431
+
432
+ // Every path was tried and refused. Say why, once, instead of silently
433
+ // pretending the LAN is what the user asked for.
434
+ if (mode !== "auto" || reasons.length) {
435
+ log(`no remote path could be brought up, staying on this Wi-Fi only: ${reasons.join("; ")}`);
436
+ }
437
+ return null;
438
+ }
439
+
440
+ /** Forget the named-tunnel record so the next run starts cloudflared again. */
441
+ export function forgetNamedTunnel(dataDir) {
442
+ try {
443
+ rmSync(namedTunnelStatePath(dataDir), { force: true });
444
+ } catch {
445
+ // Nothing to forget.
446
+ }
447
+ }
448
+
449
+ export { tunnelStatePath };
package/src/server.js CHANGED
@@ -419,6 +419,7 @@ export async function createBridge(options = {}) {
419
419
  advertisedOrigin: config.advertisedOrigin,
420
420
  accessKeyRequired: Boolean(config.accessKey),
421
421
  autostart: config.autostart === true,
422
+ remoteProvider: config.remoteProvider,
422
423
  });
423
424
  }
424
425
 
@@ -989,6 +990,9 @@ export async function startBridge() {
989
990
  bridge.server.listen(bridge.config.port, bridge.config.host, resolve);
990
991
  });
991
992
  console.log(`AgentBridge running at http://127.0.0.1:${bridge.config.port}`);
993
+ if (bridge.config.remoteProvider) {
994
+ console.log(`Remote path: ${bridge.config.remoteProvider} → ${bridge.config.advertisedOrigin}`);
995
+ }
992
996
  console.log(`WeChat scan target: ${bridge.getCurrentPairing().url}`);
993
997
  // Silence here used to mean a QR encoding an address the phone could never reach.
994
998
  if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {