@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/.env.example +21 -0
- package/README.md +199 -73
- package/docs/RELEASING.md +15 -5
- package/package.json +9 -2
- package/src/cli.js +108 -9
- package/src/config.js +4 -0
- package/src/remote.js +449 -0
- package/src/server.js +4 -0
- package/src/tunnel.js +448 -0
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
|
|
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 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 <
|
|
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
|
-
|
|
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") {
|