@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/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
- function firstLanAddress() {
6
- for (const entries of Object.values(os.networkInterfaces())) {
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
- if (entry.family === "IPv4" && !entry.internal) return entry.address;
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
- return "127.0.0.1";
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
- return json(res, shuttingDown ? 503 : 200, { ok: !shuttingDown, shuttingDown, lastSeq: eventStore.lastSeq });
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