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