botmux 2.91.0 → 2.92.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.
Files changed (62) hide show
  1. package/dist/bot-registry.d.ts +1 -1
  2. package/dist/bot-registry.d.ts.map +1 -1
  3. package/dist/bot-registry.js +9 -7
  4. package/dist/bot-registry.js.map +1 -1
  5. package/dist/cli/dashboard-endpoint.d.ts +9 -6
  6. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  7. package/dist/cli/dashboard-endpoint.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +11 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/daemon.d.ts +3 -0
  12. package/dist/daemon.d.ts.map +1 -1
  13. package/dist/daemon.js +19 -2
  14. package/dist/daemon.js.map +1 -1
  15. package/dist/dashboard/bot-payload.js +1 -1
  16. package/dist/dashboard/bot-payload.js.map +1 -1
  17. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  18. package/dist/dashboard/web/bot-defaults.js +3 -2
  19. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  20. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  21. package/dist/dashboard/web/i18n.js +4 -2
  22. package/dist/dashboard/web/i18n.js.map +1 -1
  23. package/dist/dashboard-web/app.js +4 -3
  24. package/dist/dashboard.js +118 -3
  25. package/dist/dashboard.js.map +1 -1
  26. package/dist/i18n/en.js +3 -3
  27. package/dist/i18n/en.js.map +1 -1
  28. package/dist/i18n/zh.js +3 -3
  29. package/dist/i18n/zh.js.map +1 -1
  30. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  31. package/dist/im/lark/event-dispatcher.js +12 -7
  32. package/dist/im/lark/event-dispatcher.js.map +1 -1
  33. package/dist/im/lark/relay-target-routing.d.ts.map +1 -1
  34. package/dist/im/lark/relay-target-routing.js +7 -2
  35. package/dist/im/lark/relay-target-routing.js.map +1 -1
  36. package/dist/im/lark/reply-mode-command.d.ts.map +1 -1
  37. package/dist/im/lark/reply-mode-command.js +3 -1
  38. package/dist/im/lark/reply-mode-command.js.map +1 -1
  39. package/dist/platform/bind.d.ts.map +1 -1
  40. package/dist/platform/bind.js +2 -1
  41. package/dist/platform/bind.js.map +1 -1
  42. package/dist/platform/tunnel-client.d.ts +10 -0
  43. package/dist/platform/tunnel-client.d.ts.map +1 -1
  44. package/dist/platform/tunnel-client.js +2 -0
  45. package/dist/platform/tunnel-client.js.map +1 -1
  46. package/dist/services/card-prefs-store.d.ts +1 -1
  47. package/dist/services/card-prefs-store.d.ts.map +1 -1
  48. package/dist/services/card-prefs-store.js +5 -5
  49. package/dist/services/card-prefs-store.js.map +1 -1
  50. package/dist/services/chat-reply-mode-store.d.ts +1 -1
  51. package/dist/services/chat-reply-mode-store.d.ts.map +1 -1
  52. package/dist/services/chat-reply-mode-store.js +17 -3
  53. package/dist/services/chat-reply-mode-store.js.map +1 -1
  54. package/dist/setup/cli-selection.d.ts +26 -3
  55. package/dist/setup/cli-selection.d.ts.map +1 -1
  56. package/dist/setup/cli-selection.js +53 -4
  57. package/dist/setup/cli-selection.js.map +1 -1
  58. package/dist/utils/listen-with-probe.d.ts +20 -1
  59. package/dist/utils/listen-with-probe.d.ts.map +1 -1
  60. package/dist/utils/listen-with-probe.js +48 -6
  61. package/dist/utils/listen-with-probe.js.map +1 -1
  62. package/package.json +1 -1
package/dist/dashboard.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/dashboard.ts
2
- import { createServer } from 'node:http';
2
+ import { createServer, get as httpGet } from 'node:http';
3
3
  import { createServer as createTcpServer } from 'node:net';
4
4
  import { readFileSync, existsSync, mkdirSync, statSync, createReadStream, } from 'node:fs';
5
5
  import { atomicWriteFileSync } from './utils/atomic-write.js';
@@ -52,7 +52,8 @@ import { installDashboardSkill, parseDashboardSkillInstallRequest, parseInstallL
52
52
  import { botDefaultsPayload, botSummaryPayload } from './dashboard/bot-payload.js';
53
53
  import { isValidRoleProfileId } from './services/role-profile-store.js';
54
54
  import { mergeSafeInsightOverviews } from './services/insight/report.js';
55
- import { readPlatformBinding } from './platform/binding.js';
55
+ import { watch as fsWatch } from 'node:fs';
56
+ import { readPlatformBinding, PLATFORM_BINDING_PATH } from './platform/binding.js';
56
57
  import { startPlatformTunnelClient } from './platform/tunnel-client.js';
57
58
  import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js';
58
59
  const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
@@ -118,6 +119,37 @@ function dashboardPortAvailable(port) {
118
119
  // wildcard bind still succeeds, causing CLI HMAC calls to hit that process.
119
120
  return tcpPortAvailable('127.0.0.1', port);
120
121
  }
122
+ // Per-process random marker served at /__selfcheck. Lets verifyDashboardBinding
123
+ // confirm a loopback request to our just-bound wildcard port reaches THIS
124
+ // process and not a shadow holding 127.0.0.1:port. The value is meaningless to
125
+ // anyone else, so exposing it is safe.
126
+ const DASHBOARD_SELF_NONCE = randomBytes(16).toString('hex');
127
+ /**
128
+ * Post-bind loopback identity check handed to listenWithProbe (verifyBound).
129
+ * dashboardPortAvailable is a PRE-bind gate, but on macOS a loopback occupant
130
+ * can appear in the race window between that check and the wildcard listen, and
131
+ * a 0.0.0.0 bind succeeds anyway while loopback routing favours the occupant —
132
+ * so the dashboard would advertise a port it doesn't actually own on loopback.
133
+ * This runs AFTER listen: dial 127.0.0.1:port/__selfcheck and require OUR nonce
134
+ * back. A shadow answers with its own body/404 → reject → listenWithProbe steps
135
+ * up. Number-independent: it works no matter which port or who is shadowing.
136
+ * Loopback-host binds can't be shadowed, so they short-circuit to true.
137
+ */
138
+ function verifyDashboardBinding(port) {
139
+ if (!isWildcardBindHost(config.dashboard.host))
140
+ return Promise.resolve(true);
141
+ return new Promise((resolve) => {
142
+ const req = httpGet({ host: '127.0.0.1', port, path: '/__selfcheck', agent: false }, (res) => {
143
+ let body = '';
144
+ res.setEncoding('utf8');
145
+ res.on('data', (c) => { body += c; if (body.length > 128)
146
+ req.destroy(); });
147
+ res.on('end', () => resolve(res.statusCode === 200 && body === DASHBOARD_SELF_NONCE));
148
+ });
149
+ req.setTimeout(2000, () => { req.destroy(); resolve(false); });
150
+ req.on('error', () => resolve(false));
151
+ });
152
+ }
121
153
  /** Sign a loopback request to a daemon's write-link route. The daemon verifies
122
154
  * with the same .dashboard-secret, so only a caller that can read the secret —
123
155
  * the dashboard — can mint write tokens; a bare local process that only knows
@@ -807,6 +839,13 @@ const server = createServer(async (req, res) => {
807
839
  if (url.pathname === '/__health') {
808
840
  return jsonRes(res, 200, { ok: true });
809
841
  }
842
+ // Loopback self-identification (no auth): echoes this process's nonce so the
843
+ // post-bind shadow check (listen-with-probe verifyBound) can distinguish our
844
+ // server from a process shadowing 127.0.0.1:port. Returns only the nonce.
845
+ if (url.pathname === '/__selfcheck') {
846
+ res.writeHead(200, { 'content-type': 'text/plain' });
847
+ return res.end(DASHBOARD_SELF_NONCE);
848
+ }
810
849
  if (await handleWebhookRoute(req, res, url, {
811
850
  proxyToDaemon,
812
851
  createLifecycleGroup: createLifecycleGroupForWebhook,
@@ -2026,6 +2065,11 @@ const server = createServer(async (req, res) => {
2026
2065
  }
2027
2066
  const creator = registry.getByAppId(pick.creatorLarkAppId);
2028
2067
  const merged = new Set([...explicit, ...pick.userOpenIds]);
2068
+ // 跨 app 邀请通道:按 union_id 加人(open_id 是 app 作用域的,union_id 稳定,
2069
+ // 由 creator daemon 解析成本 app 的 open_id 再加)。平台「拉群」即走这条。
2070
+ const ownerUnionIds = Array.isArray(parsed.ownerUnionIds)
2071
+ ? parsed.ownerUnionIds.filter((x) => typeof x === 'string')
2072
+ : [];
2029
2073
  // Auto-invite/transfer/notify target: prefer the explicit open_id passed
2030
2074
  // by the caller (rare API consumer use), else the creator bot's first
2031
2075
  // resolved allowlist entry.
@@ -2034,6 +2078,7 @@ const server = createServer(async (req, res) => {
2034
2078
  name: typeof parsed.name === 'string' ? parsed.name : undefined,
2035
2079
  larkAppIds: selectedIds,
2036
2080
  userOpenIds: [...merged],
2081
+ ownerUnionIds,
2037
2082
  // Auto-transfer ownership to the auto-invited operator. Scope-safe
2038
2083
  // because the open_id was sourced from the creator bot's own allowlist.
2039
2084
  transferOwnerTo: autoInvited ?? undefined,
@@ -2249,6 +2294,7 @@ listenWithProbe({
2249
2294
  port: config.dashboard.port,
2250
2295
  host: config.dashboard.host,
2251
2296
  portAvailable: dashboardPortAvailable,
2297
+ verifyBound: verifyDashboardBinding,
2252
2298
  log: (m) => logger.warn(`[dashboard] ${m}`),
2253
2299
  }).then((port) => {
2254
2300
  boundDashboardPort = port;
@@ -2260,6 +2306,7 @@ listenWithProbe({
2260
2306
  }
2261
2307
  logger.info(`[dashboard] listening on ${config.dashboard.host}:${port}`);
2262
2308
  startPlatformTunnelIfBound();
2309
+ watchPlatformBinding(); // bind 后无需重启 daemon,自动连接平台
2263
2310
  }).catch((err) => {
2264
2311
  logger.error(`[dashboard] could not bind near ${config.dashboard.host}:${config.dashboard.port} after probing — set BOTMUX_DASHBOARD_PORT to a free port. ${err.message}`);
2265
2312
  process.exit(1);
@@ -2276,6 +2323,8 @@ const federationSync = setInterval(() => {
2276
2323
  federationSync.unref();
2277
2324
  // 中心化平台隧道(已绑定才启动;每台机器一个,跑在 dashboard 进程里)
2278
2325
  let platformTunnel = null;
2326
+ // 当前隧道对应的绑定身份(platformUrl|machineId|machineToken);用于判断 bind 变化是否需要重连
2327
+ let platformBindingKey = null;
2279
2328
  function readBotmuxVersion() {
2280
2329
  try {
2281
2330
  const pkg = JSON.parse(readFileSync(join(dirname(__dirname), 'package.json'), 'utf8'));
@@ -2285,25 +2334,91 @@ function readBotmuxVersion() {
2285
2334
  return 'unknown';
2286
2335
  }
2287
2336
  }
2337
+ /** 读本机 bots-info.json,转成上报给平台的 bot 概要(人→机器→bot + 拉群用)。 */
2338
+ function readPlatformBotsInfo() {
2339
+ try {
2340
+ const fp = join(config.session.dataDir, 'bots-info.json');
2341
+ if (!existsSync(fp))
2342
+ return [];
2343
+ const entries = JSON.parse(readFileSync(fp, 'utf8'));
2344
+ if (!Array.isArray(entries))
2345
+ return [];
2346
+ return entries
2347
+ .map((e) => ({
2348
+ appId: e.larkAppId || '',
2349
+ openId: e.botOpenId ?? null,
2350
+ name: e.botName || e.larkAppId || 'bot',
2351
+ avatar: e.botAvatarUrl || undefined,
2352
+ cli: e.cliId,
2353
+ }))
2354
+ .filter((b) => b.appId);
2355
+ }
2356
+ catch {
2357
+ return [];
2358
+ }
2359
+ }
2288
2360
  function startPlatformTunnelIfBound() {
2289
2361
  try {
2290
2362
  const binding = readPlatformBinding();
2291
- if (!binding)
2363
+ if (!binding) {
2364
+ platformBindingKey = null;
2292
2365
  return;
2366
+ }
2293
2367
  const version = readBotmuxVersion();
2294
2368
  platformTunnel = startPlatformTunnelClient({
2295
2369
  binding,
2296
2370
  getDashboardPort: () => boundDashboardPort,
2297
2371
  getDashboardToken: () => activeToken,
2298
2372
  getVersion: () => version,
2373
+ getBots: () => readPlatformBotsInfo(),
2299
2374
  log: (msg, extra) => logger.info(`[platform-tunnel] ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}`),
2300
2375
  });
2376
+ platformBindingKey = `${binding.platformUrl}|${binding.machineId}|${binding.machineToken}`;
2301
2377
  logger.info(`[platform-tunnel] 绑定到 ${binding.platformUrl},启动隧道`);
2302
2378
  }
2303
2379
  catch (e) {
2304
2380
  logger.warn(`[platform-tunnel] 启动失败: ${e.message}`);
2305
2381
  }
2306
2382
  }
2383
+ /**
2384
+ * 监听绑定文件变化:`botmux bind` 写入后无需重启 daemon,自动连接平台。
2385
+ * 只在绑定「身份」(platformUrl/machineId/machineToken) 变化时重连——团队成员变化也会改写该文件
2386
+ * (tunnel-client 自己写的),那种不重连,避免反复重启。
2387
+ */
2388
+ function watchPlatformBinding() {
2389
+ let timer = null;
2390
+ const onChange = () => {
2391
+ if (timer)
2392
+ clearTimeout(timer);
2393
+ timer = setTimeout(() => {
2394
+ const b = readPlatformBinding();
2395
+ const key = b ? `${b.platformUrl}|${b.machineId}|${b.machineToken}` : null;
2396
+ if (key === platformBindingKey)
2397
+ return; // 没变(或仅团队变化)→ 不重连
2398
+ logger.info('[platform-tunnel] 检测到绑定变化,重连平台');
2399
+ try {
2400
+ platformTunnel?.stop();
2401
+ }
2402
+ catch {
2403
+ /* ignore */
2404
+ }
2405
+ platformTunnel = null;
2406
+ startPlatformTunnelIfBound();
2407
+ }, 800);
2408
+ };
2409
+ try {
2410
+ // 监听所在目录而非文件本身——绑定走原子写(临时文件 + rename),直接 watch 文件会在 rename 后失效
2411
+ const dir = dirname(PLATFORM_BINDING_PATH);
2412
+ const base = PLATFORM_BINDING_PATH.slice(dir.length + 1);
2413
+ fsWatch(dir, (_event, filename) => {
2414
+ if (!filename || filename === base)
2415
+ onChange();
2416
+ });
2417
+ }
2418
+ catch (e) {
2419
+ logger.warn(`[platform-tunnel] 绑定文件监听启动失败: ${e.message}`);
2420
+ }
2421
+ }
2307
2422
  // Graceful shutdown
2308
2423
  function shutdown() {
2309
2424
  for (const off of subs.values())