@chenacc/agentbridge 0.1.0 → 0.1.2
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 +34 -12
- package/package.json +3 -2
- package/public/styles.css +1073 -1073
- package/src/config.js +78 -6
- package/src/server.js +18 -1
package/src/config.js
CHANGED
|
@@ -2,13 +2,81 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Adapter names that never reach the phone, or only reach it over an overlay.
|
|
7
|
+
*
|
|
8
|
+
* A Windows laptop normally carries several phantom adapters: Hyper-V switches,
|
|
9
|
+
* WSL, "本地连接*" (Wi-Fi Direct), Bluetooth PAN and — with Tailscale installed
|
|
10
|
+
* but logged out — a 169.254.x.x link-local fallback. `os.networkInterfaces()`
|
|
11
|
+
* returns them in no meaningful order, so taking the first one can hand the
|
|
12
|
+
* phone an address with no route to anything. That is exactly what shipped in
|
|
13
|
+
* 0.1.0: the QR encoded the logged-out Tailscale adapter.
|
|
14
|
+
*/
|
|
15
|
+
const OVERLAY_ADAPTER_HINTS = ["tailscale", "zerotier", "wireguard", "hamachi", "radmin", "vpn"];
|
|
16
|
+
const VIRTUAL_ADAPTER_HINTS = [
|
|
17
|
+
"vethernet", "hyper-v", "wsl", "vmware", "virtualbox", "docker", "npcap",
|
|
18
|
+
"loopback", "bluetooth", "蓝牙", "wi-fi direct", "wifi direct", "tap-", "utun",
|
|
19
|
+
];
|
|
20
|
+
const PHYSICAL_ADAPTER_HINTS = ["wlan", "wi-fi", "wifi", "无线", "ethernet", "以太网", "en0", "eth", "enp", "ens", "本地连接"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How usable an address is from another device, 0 meaning "not at all".
|
|
24
|
+
*
|
|
25
|
+
* Loopback and 169.254 link-local are hard zeros: a link-local address means the
|
|
26
|
+
* adapter never got a lease, so nothing outside the cable can reach it. Private
|
|
27
|
+
* RFC 1918 ranges are the intended path, 100.64/10 (Tailscale's CGNAT range) is
|
|
28
|
+
* reachable only from a phone that is also on the tailnet, and a plain public
|
|
29
|
+
* address on a NIC still beats nothing.
|
|
30
|
+
*/
|
|
31
|
+
function addressScore(address) {
|
|
32
|
+
const [a, b] = address.split(".").map(Number);
|
|
33
|
+
if (!Number.isInteger(a) || !Number.isInteger(b)) return 0;
|
|
34
|
+
if (a === 0 || a === 127 || a >= 224) return 0;
|
|
35
|
+
if (a === 169 && b === 254) return 0;
|
|
36
|
+
if (a === 192 && b === 168) return 100;
|
|
37
|
+
if (a === 10) return 95;
|
|
38
|
+
if (a === 172 && b >= 16 && b <= 31) return 90;
|
|
39
|
+
if (a === 100 && b >= 64 && b <= 127) return 60;
|
|
40
|
+
return 50;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function adapterScore(name = "") {
|
|
44
|
+
const lower = String(name).toLowerCase();
|
|
45
|
+
// Hosted-network names such as "本地连接* 10" carry an asterisk; the star is what
|
|
46
|
+
// distinguishes them from the plain "本地连接" of a real Ethernet card.
|
|
47
|
+
if (lower.includes("*")) return -120;
|
|
48
|
+
if (OVERLAY_ADAPTER_HINTS.some((hint) => lower.includes(hint))) return -40;
|
|
49
|
+
if (VIRTUAL_ADAPTER_HINTS.some((hint) => lower.includes(hint))) return -120;
|
|
50
|
+
if (PHYSICAL_ADAPTER_HINTS.some((hint) => lower.includes(hint))) return 25;
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every IPv4 address a phone could plausibly open, best first.
|
|
56
|
+
*
|
|
57
|
+
* Exported for tests: the failure this guards against depends on the machine's
|
|
58
|
+
* adapter list, which no CI runner reproduces.
|
|
59
|
+
*/
|
|
60
|
+
export function rankLanAddresses(interfaces = os.networkInterfaces()) {
|
|
61
|
+
const ranked = [];
|
|
62
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
7
63
|
for (const entry of entries ?? []) {
|
|
8
|
-
|
|
64
|
+
// `family` is "IPv4" on modern Node and 4 on older builds.
|
|
65
|
+
if (entry.family !== "IPv4" && entry.family !== 4) continue;
|
|
66
|
+
if (entry.internal) continue;
|
|
67
|
+
// A zero address score is a hard no: a friendly adapter name cannot make a
|
|
68
|
+
// link-local or loopback address routable.
|
|
69
|
+
const score = addressScore(entry.address) + adapterScore(name);
|
|
70
|
+
if (addressScore(entry.address) === 0 || score <= 0) continue;
|
|
71
|
+
ranked.push({ name, address: entry.address, score });
|
|
9
72
|
}
|
|
10
73
|
}
|
|
11
|
-
|
|
74
|
+
// A stable sort keeps the OS order for equally scored adapters.
|
|
75
|
+
return ranked.sort((left, right) => right.score - left.score);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function firstLanAddress(interfaces) {
|
|
79
|
+
return rankLanAddresses(interfaces)[0]?.address ?? "127.0.0.1";
|
|
12
80
|
}
|
|
13
81
|
|
|
14
82
|
function parseProjects(raw, workspace) {
|
|
@@ -98,6 +166,10 @@ export function loadConfig(env = process.env, workspace = process.cwd()) {
|
|
|
98
166
|
pairingTtlMs: Number.parseInt(env.AGENTBRIDGE_PAIRING_TTL_MS ?? "300000", 10),
|
|
99
167
|
sessionTtlMs: Number.parseInt(env.AGENTBRIDGE_SESSION_TTL_MS ?? "259200000", 10),
|
|
100
168
|
machineName: env.AGENTBRIDGE_MACHINE_NAME ?? os.hostname(),
|
|
169
|
+
// Set by the DSH plugin when it spawns the bridge itself. It lets the plugin
|
|
170
|
+
// recognise its own child in /api/health and restart it to change the public
|
|
171
|
+
// address, without ever touching a bridge the user started by hand.
|
|
172
|
+
autostart: env.AGENTBRIDGE_AUTOSTART === "1",
|
|
101
173
|
// Outbound notifications: unset webhook means the feature is simply off.
|
|
102
174
|
notifyWebhook: pick(env, "AGENTBRIDGE_NOTIFY_WEBHOOK", notifyFile.webhook),
|
|
103
175
|
notifyFormat: pick(env, "AGENTBRIDGE_NOTIFY_FORMAT", notifyFile.format ?? "json").toLowerCase(),
|
|
@@ -105,8 +177,8 @@ export function loadConfig(env = process.env, workspace = process.cwd()) {
|
|
|
105
177
|
// PushPlus-style services need the token inside the request body.
|
|
106
178
|
notifyToken: pick(env, "AGENTBRIDGE_NOTIFY_TOKEN", notifyFile.token),
|
|
107
179
|
// Some providers (PushPlus) also take a delivery channel, e.g. `clawbot`.
|
|
108
|
-
notifyChannel: pick(env, "AGENTBRIDGE_NOTIFY_CHANNEL", notifyFile.channel),
|
|
109
|
-
// Per-bridge mute, toggled from the phone; persisted so it survives a restart.
|
|
180
|
+
notifyChannel: pick(env, "AGENTBRIDGE_NOTIFY_CHANNEL", notifyFile.channel),
|
|
181
|
+
// Per-bridge mute, toggled from the phone; persisted so it survives a restart.
|
|
110
182
|
notifyMuted: notifyFile.muted === true,
|
|
111
183
|
// Opt-in: putting the access key into a third-party chat is a real disclosure,
|
|
112
184
|
// so the notification link stays keyless unless the operator asks for it.
|
package/src/server.js
CHANGED
|
@@ -390,7 +390,17 @@ export async function createBridge(options = {}) {
|
|
|
390
390
|
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
391
391
|
try {
|
|
392
392
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
393
|
-
|
|
393
|
+
// The advertised origin is public information (it is printed inside every
|
|
394
|
+
// QR code), and exposing it here is what lets the DSH plugin notice that a
|
|
395
|
+
// running bridge still advertises a stale address.
|
|
396
|
+
return json(res, shuttingDown ? 503 : 200, {
|
|
397
|
+
ok: !shuttingDown,
|
|
398
|
+
shuttingDown,
|
|
399
|
+
lastSeq: eventStore.lastSeq,
|
|
400
|
+
advertisedOrigin: config.advertisedOrigin,
|
|
401
|
+
accessKeyRequired: Boolean(config.accessKey),
|
|
402
|
+
autostart: config.autostart === true,
|
|
403
|
+
});
|
|
394
404
|
}
|
|
395
405
|
|
|
396
406
|
if (shuttingDown) throw Object.assign(new Error("AgentBridge is shutting down"), { statusCode: 503, code: "BRIDGE_SHUTTING_DOWN" });
|
|
@@ -946,6 +956,13 @@ export async function startBridge() {
|
|
|
946
956
|
});
|
|
947
957
|
console.log(`AgentBridge running at http://127.0.0.1:${bridge.config.port}`);
|
|
948
958
|
console.log(`WeChat scan target: ${bridge.getCurrentPairing().url}`);
|
|
959
|
+
// Silence here used to mean a QR encoding an address the phone could never reach.
|
|
960
|
+
if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {
|
|
961
|
+
console.warn(
|
|
962
|
+
"No routable LAN address found, so the QR code points at loopback and a phone cannot open it. "
|
|
963
|
+
+ "Connect the machine to Wi-Fi/Ethernet, or set AGENTBRIDGE_PUBLIC_ORIGIN to the address the phone should use.",
|
|
964
|
+
);
|
|
965
|
+
}
|
|
949
966
|
return bridge;
|
|
950
967
|
}
|
|
951
968
|
|