botmux 2.75.1 → 2.76.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Loopback HMAC client for the dashboard process's `/__cli/*` endpoints, used by
3
+ * `botmux dashboard` (rotate) and the post-start/restart hint (current).
4
+ *
5
+ * Two subtleties this module exists to handle correctly:
6
+ *
7
+ * 1. **404 is ambiguous.** Only the dashboard's `/__cli/current` returns 404 to
8
+ * mean "no token minted yet" (`{ error: 'no_active_token' }`). Any *other*
9
+ * 404 means the request hit a server that doesn't speak the `/__cli`
10
+ * protocol — most commonly the daemon IPC server, whose unknown-route 404 is
11
+ * `{ error: 'not_found', path }`. Conflating the two surfaces the infamous
12
+ * misleading `Rotation failed: no-active-token` when the real problem is that
13
+ * `.dashboard-port` points at the wrong service.
14
+ *
15
+ * 2. **`.dashboard-port` can go stale.** The dashboard and the daemon IPC server
16
+ * both `listenWithProbe` upward from adjacent base ports (7891 vs 7892) with
17
+ * heavily overlapping probe ranges, so across restarts the recorded dashboard
18
+ * port can end up owned by an IPC server. When the recorded port answers as
19
+ * the *wrong service*, we rediscover the real dashboard by HMAC-probing the
20
+ * probe range (only the genuine dashboard can validate the signature) and
21
+ * self-heal `.dashboard-port`.
22
+ */
23
+ export type DashboardEndpoint = '/__cli/rotate' | '/__cli/current';
24
+ export type DashboardFailReason = 'no-secret' | 'unreachable' | 'http-error' | 'no-active-token' | 'wrong-service';
25
+ export type DashboardResult = {
26
+ ok: true;
27
+ url: string;
28
+ } | {
29
+ ok: false;
30
+ reason: DashboardFailReason;
31
+ detail?: string;
32
+ };
33
+ type FetchImpl = typeof fetch;
34
+ /**
35
+ * Classify a 404 from a `/__cli/*` request. A genuine "no token yet" only comes
36
+ * from `/__cli/current` carrying `{ error: 'no_active_token' }`; everything else
37
+ * means the port is answering for some other service (daemon IPC, a stray HTTP
38
+ * server, …), not the dashboard rotate/current routes.
39
+ */
40
+ export declare function classifyDashboard404(path: DashboardEndpoint, bodyText: string): DashboardResult;
41
+ /** Issue a single HMAC-authed request to one candidate port. */
42
+ export declare function requestDashboardAt(opts: {
43
+ host: string;
44
+ port: number;
45
+ path: DashboardEndpoint;
46
+ secret: string;
47
+ fetchImpl?: FetchImpl;
48
+ }): Promise<DashboardResult>;
49
+ /**
50
+ * Resolve the dashboard URL for `path`, trying the recorded port first and
51
+ * self-healing the port file when it points at the wrong service.
52
+ */
53
+ export declare function callDashboard(opts: {
54
+ configDir: string;
55
+ defaultPort: number;
56
+ host?: string;
57
+ envPort?: string;
58
+ probeSpan?: number;
59
+ persistPort?: boolean;
60
+ path: DashboardEndpoint;
61
+ fetchImpl?: FetchImpl;
62
+ }): Promise<DashboardResult>;
63
+ export {};
64
+ //# sourceMappingURL=dashboard-endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard-endpoint.d.ts","sourceRoot":"","sources":["../../src/cli/dashboard-endpoint.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,MAAM,iBAAiB,GAAG,eAAe,GAAG,gBAAgB,CAAC;AAEnE,MAAM,MAAM,mBAAmB,GAC3B,WAAW,GACX,aAAa,GACb,YAAY,GACZ,iBAAiB,GACjB,eAAe,CAAC;AAEpB,MAAM,MAAM,eAAe,GACvB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhE,KAAK,SAAS,GAAG,OAAO,KAAK,CAAC;AAE9B;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,GAAG,eAAe,CAY/F;AAED,gEAAgE;AAChE,wBAAsB,kBAAkB,CAAC,IAAI,EAAE;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,iBAAiB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,OAAO,CAAC,eAAe,CAAC,CA8B3B;AAOD;;;GAGG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,IAAI,EAAE,iBAAiB,CAAC;IACxB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4C3B"}
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { atomicWriteFileSync } from '../utils/atomic-write.js';
4
+ import { cliAuthBind, signCliAuth } from '../dashboard/auth.js';
5
+ /**
6
+ * Classify a 404 from a `/__cli/*` request. A genuine "no token yet" only comes
7
+ * from `/__cli/current` carrying `{ error: 'no_active_token' }`; everything else
8
+ * means the port is answering for some other service (daemon IPC, a stray HTTP
9
+ * server, …), not the dashboard rotate/current routes.
10
+ */
11
+ export function classifyDashboard404(path, bodyText) {
12
+ let body = null;
13
+ try {
14
+ body = JSON.parse(bodyText);
15
+ }
16
+ catch { /* non-JSON body → wrong service */ }
17
+ const err = (body && typeof body === 'object') ? body.error : undefined;
18
+ if (path === '/__cli/current' && err === 'no_active_token') {
19
+ return { ok: false, reason: 'no-active-token' };
20
+ }
21
+ return {
22
+ ok: false,
23
+ reason: 'wrong-service',
24
+ detail: bodyText ? `404 ${bodyText.slice(0, 200)}` : '404',
25
+ };
26
+ }
27
+ /** Issue a single HMAC-authed request to one candidate port. */
28
+ export async function requestDashboardAt(opts) {
29
+ const { host, port, path, secret } = opts;
30
+ const fetchImpl = opts.fetchImpl ?? fetch;
31
+ // Bind the credential to method + path + the port we're dialing. A malicious
32
+ // server handed these headers during discovery therefore can't forward them
33
+ // to a different `/__cli/*` route or to the real dashboard on another port —
34
+ // the verifier reconstructs the bind from the port IT bound, so any forward
35
+ // mismatches the signature (and the attacker can't re-sign without the secret).
36
+ const { ts, nonce, sig } = signCliAuth(secret, cliAuthBind('POST', path, port));
37
+ let res;
38
+ try {
39
+ res = await fetchImpl(`http://${host}:${port}${path}`, {
40
+ method: 'POST',
41
+ headers: {
42
+ 'X-Botmux-Cli-Ts': ts,
43
+ 'X-Botmux-Cli-Nonce': nonce,
44
+ 'X-Botmux-Cli-Auth': sig,
45
+ },
46
+ });
47
+ }
48
+ catch {
49
+ return { ok: false, reason: 'unreachable' };
50
+ }
51
+ if (res.status === 404)
52
+ return classifyDashboard404(path, await res.text().catch(() => ''));
53
+ if (!res.ok) {
54
+ return { ok: false, reason: 'http-error', detail: `${res.status} ${await res.text().catch(() => '')}` };
55
+ }
56
+ const body = await res.json().catch(() => ({}));
57
+ if (!body.url)
58
+ return { ok: false, reason: 'http-error', detail: 'malformed response (no url)' };
59
+ return { ok: true, url: body.url };
60
+ }
61
+ /** A result that proves we actually reached the dashboard (vs. wrong port). */
62
+ function reachedDashboard(r) {
63
+ return r.ok || (!r.ok && (r.reason === 'no-active-token' || r.reason === 'http-error'));
64
+ }
65
+ /**
66
+ * Resolve the dashboard URL for `path`, trying the recorded port first and
67
+ * self-healing the port file when it points at the wrong service.
68
+ */
69
+ export async function callDashboard(opts) {
70
+ const host = opts.host ?? '127.0.0.1';
71
+ const probeSpan = opts.probeSpan ?? 20;
72
+ const persistPort = opts.persistPort ?? true;
73
+ const fetchImpl = opts.fetchImpl ?? fetch;
74
+ const secretPath = join(opts.configDir, '.dashboard-secret');
75
+ if (!existsSync(secretPath))
76
+ return { ok: false, reason: 'no-secret' };
77
+ const secret = readFileSync(secretPath, 'utf8').trim();
78
+ const portFile = join(opts.configDir, '.dashboard-port');
79
+ const recorded = (existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : '')
80
+ || opts.envPort
81
+ || String(opts.defaultPort);
82
+ const candidate = Number(recorded);
83
+ // 1. Try the recorded port. A success — or any state that proves we reached
84
+ // the dashboard (no-active-token / http-error) — is returned as-is.
85
+ const first = await requestDashboardAt({ host, port: candidate, path: opts.path, secret, fetchImpl });
86
+ if (reachedDashboard(first))
87
+ return first;
88
+ // 2. Only `wrong-service` warrants rediscovery: some server answered on the
89
+ // recorded port but it's not the dashboard, so the port file is stale.
90
+ // (`unreachable` during boot resolves by retrying the same port, not by
91
+ // scanning — so we leave it to the caller's retry loop.)
92
+ if (first.ok || first.reason !== 'wrong-service')
93
+ return first;
94
+ const base = Number(opts.envPort || opts.defaultPort);
95
+ for (let p = base; p <= base + probeSpan; p++) {
96
+ if (p === candidate)
97
+ continue;
98
+ // Probe read-only (`/__cli/current`) so discovery never mints a token on a
99
+ // server we're merely identifying. Only the real dashboard can answer the
100
+ // HMAC-gated route as `ok` or `no-active-token`.
101
+ const probe = await requestDashboardAt({ host, port: p, path: '/__cli/current', secret, fetchImpl });
102
+ if (probe.ok || (!probe.ok && probe.reason === 'no-active-token')) {
103
+ if (persistPort) {
104
+ try {
105
+ atomicWriteFileSync(portFile, String(p));
106
+ }
107
+ catch { /* best-effort self-heal */ }
108
+ }
109
+ // Found the dashboard — perform the actually-requested op on its port.
110
+ return requestDashboardAt({ host, port: p, path: opts.path, secret, fetchImpl });
111
+ }
112
+ }
113
+ // No dashboard found in the probe range; surface the original wrong-service.
114
+ return first;
115
+ }
116
+ //# sourceMappingURL=dashboard-endpoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard-endpoint.js","sourceRoot":"","sources":["../../src/cli/dashboard-endpoint.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAo8IA;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EACvC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,qBAAqB,EAAE,SAAS,CAAC,EAC9F,KAAK,EAAE,MAAM,EACb,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,2BAA2B,EAAE,UAAU,GAAG,IAAI,CAAC,GACzF,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA8F7B"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAk7IA;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EACvC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,qBAAqB,EAAE,SAAS,CAAC,EAC9F,KAAK,EAAE,MAAM,EACb,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,2BAA2B,EAAE,UAAU,GAAG,IAAI,CAAC,GACzF,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA8F7B"}
package/dist/cli.js CHANGED
@@ -42,6 +42,7 @@ import { invalidWorkingDirs } from './utils/working-dir.js';
42
42
  import { firstPositional } from './cli/arg-utils.js';
43
43
  import { dispatchPrimaryMessage, findStdinAliasAttachment, sendFileAttachments } from './cli/send-dispatch.js';
44
44
  import { buildPm2SpawnCommand } from './cli/pm2-command.js';
45
+ import { callDashboard } from './cli/dashboard-endpoint.js';
45
46
  import { rejectLikelyWindowsStdinMojibake } from './cli/stdin-encoding.js';
46
47
  import { formatBotInfoEntriesForCli, formatChatBotsForCli, } from './cli/bots-list-output.js';
47
48
  import { buildFooterAddressing, hasKnownBotMention, knownBotOpenIdsFromCrossRef, orderedFooterRecipients, } from './utils/bot-routing.js';
@@ -1368,47 +1369,18 @@ function cmdUpgrade() {
1368
1369
  }
1369
1370
  }
1370
1371
  /**
1371
- * Call one of the dashboard's loopback HMAC `/__cli/*` endpoints.
1372
- * - `/__cli/rotate` mints a fresh token and returns its URL, invalidating the
1373
- * previously-issued link.
1374
- * - `/__cli/current` returns the existing token's URL WITHOUT rotating (404 →
1375
- * no token has ever been minted → `no-active-token`).
1376
- * Returns { ok: true, url } on success, or { ok: false, reason } so callers can
1377
- * decide how to surface the failure (hard error vs soft hint).
1372
+ * Call one of the dashboard's loopback HMAC `/__cli/*` endpoints. Thin wrapper
1373
+ * over {@link callDashboard}, which handles 404 disambiguation and self-heals a
1374
+ * stale `.dashboard-port` that points at the wrong service (e.g. daemon IPC).
1375
+ * See `src/cli/dashboard-endpoint.ts` for the why.
1378
1376
  */
1379
1377
  async function callDashboardEndpoint(path) {
1380
- const SECRET_PATH = join(CONFIG_DIR, '.dashboard-secret');
1381
- if (!existsSync(SECRET_PATH))
1382
- return { ok: false, reason: 'no-secret' };
1383
- const secret = readFileSync(SECRET_PATH, 'utf8').trim();
1384
- const ts = Math.floor(Date.now() / 1000).toString();
1385
- const nonce = randomBytes(8).toString('hex');
1386
- const sig = createHmac('sha256', secret).update(`${ts}:${nonce}`).digest('base64url');
1387
- const portFile = join(CONFIG_DIR, '.dashboard-port');
1388
- const port = (existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : '')
1389
- || process.env.BOTMUX_DASHBOARD_PORT
1390
- || '7891';
1391
- let res;
1392
- try {
1393
- res = await fetch(`http://127.0.0.1:${port}${path}`, {
1394
- method: 'POST',
1395
- headers: {
1396
- 'X-Botmux-Cli-Ts': ts,
1397
- 'X-Botmux-Cli-Nonce': nonce,
1398
- 'X-Botmux-Cli-Auth': sig,
1399
- },
1400
- });
1401
- }
1402
- catch {
1403
- return { ok: false, reason: 'unreachable' };
1404
- }
1405
- if (res.status === 404)
1406
- return { ok: false, reason: 'no-active-token' };
1407
- if (!res.ok) {
1408
- return { ok: false, reason: 'http-error', detail: `${res.status} ${await res.text()}` };
1409
- }
1410
- const body = await res.json();
1411
- return { ok: true, url: body.url };
1378
+ return callDashboard({
1379
+ configDir: CONFIG_DIR,
1380
+ defaultPort: 7891,
1381
+ envPort: process.env.BOTMUX_DASHBOARD_PORT,
1382
+ path,
1383
+ });
1412
1384
  }
1413
1385
  /**
1414
1386
  * Best-effort dashboard hint printed after start/restart. Reads the LIVE link
@@ -1428,8 +1400,10 @@ async function printDashboardHintWithRetry() {
1428
1400
  return;
1429
1401
  }
1430
1402
  // Terminal states — file-backed secret/token won't appear mid-poll, unlike
1431
- // a not-yet-listening port. Don't spin on them.
1432
- if (last.reason === 'no-secret' || last.reason === 'no-active-token')
1403
+ // a not-yet-listening port. `wrong-service` means the port file points at a
1404
+ // non-dashboard server and discovery already failed to find it, so retrying
1405
+ // won't help either. Don't spin on any of them.
1406
+ if (last.reason === 'no-secret' || last.reason === 'no-active-token' || last.reason === 'wrong-service')
1433
1407
  break;
1434
1408
  await new Promise(r => setTimeout(r, stepMs));
1435
1409
  }
@@ -1440,6 +1414,9 @@ async function printDashboardHintWithRetry() {
1440
1414
  else if (last?.reason === 'no-secret') {
1441
1415
  console.log(' 面板: dashboard 凭证未就绪,启动后可用 `botmux dashboard` 获取链接');
1442
1416
  }
1417
+ else if (last?.reason === 'wrong-service') {
1418
+ console.log(' 面板: `botmux dashboard`(端口文件可能已失效,必要时 `botmux restart` 刷新)');
1419
+ }
1443
1420
  else {
1444
1421
  console.log(' 面板: `botmux dashboard`(daemon 启动中,稍后可获取链接)');
1445
1422
  }
@@ -1455,15 +1432,24 @@ async function cmdDashboard() {
1455
1432
  console.log(r.url);
1456
1433
  return;
1457
1434
  }
1435
+ const portFile = join(CONFIG_DIR, '.dashboard-port');
1436
+ const recordedPort = (existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : '')
1437
+ || process.env.BOTMUX_DASHBOARD_PORT
1438
+ || '7891';
1458
1439
  if (r.reason === 'no-secret') {
1459
1440
  console.error('Dashboard not initialised. Run `botmux restart` first.');
1460
1441
  }
1461
1442
  else if (r.reason === 'unreachable') {
1462
- const portFile = join(CONFIG_DIR, '.dashboard-port');
1463
- const port = (existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : '')
1464
- || process.env.BOTMUX_DASHBOARD_PORT
1465
- || '7891';
1466
- console.error(`dashboard process not reachable on 127.0.0.1:${port} \`botmux restart\` will start it`);
1443
+ console.error(`dashboard process not reachable on 127.0.0.1:${recordedPort} — \`botmux restart\` will start it`);
1444
+ }
1445
+ else if (r.reason === 'wrong-service') {
1446
+ // 127.0.0.1:<port> answered, but it isn't the dashboard (typically the
1447
+ // daemon IPC server holding a port the stale .dashboard-port points at),
1448
+ // and rediscovery across the probe range found no dashboard either.
1449
+ console.error(`127.0.0.1:${recordedPort} 上的服务不是 dashboard(端口文件 ~/.botmux/.dashboard-port 已失效,可能指向了 daemon IPC)。` +
1450
+ '运行 `botmux restart` 重启 dashboard 并刷新端口文件。');
1451
+ if (r.detail)
1452
+ console.error(` 详情: ${r.detail}`);
1467
1453
  }
1468
1454
  else {
1469
1455
  // `no-active-token` can't occur on rotate (it always mints); fall through.