@sleep2agi/commhub-server 0.9.0-preview.78 → 0.9.0-preview.79

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.78",
3
+ "version": "0.9.0-preview.79",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,120 @@
1
+ // GET /api/stats/routes?by=caller — per-route caller classes (token kind + User-Agent family).
2
+ // Production serves the full GET /api/status ~2.8/s and every caller arrives as 127.0.0.1 through the
3
+ // frp tunnel, with no access log; this breakdown is how we tell who it is. Pinned over real HTTP:
4
+ // - without by=caller the body is byte-for-byte what routeStats() produced before this change;
5
+ // - with it, each route carries callers [{class, count, bytes}] counted per class;
6
+ // - no token, user id, node id, username or raw User-Agent text ever reaches the output;
7
+ // - distinct classes per route are capped, the rest folded into "(other)";
8
+ // - still admin-only.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { ROUTE_MAX_CALLERS_PER_ROUTE, ROUTE_OVERFLOW_KEY, routeStats } from "./route-timing.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-route-callers-"));
17
+ process.env.HOME = DIR;
18
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
19
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
20
+
21
+ const PW = "RouteCallersPassw0rd!";
22
+ const NODE_ID = "node_route_callers_probe";
23
+ const NODE_NAME = "rc-probe-node";
24
+ const UA_MARKER = "zz-ua-marker-41c9";
25
+ let BASE = "";
26
+ let hub: any = null;
27
+ let admin = "";
28
+ let adminId = "";
29
+ let adminName = "";
30
+ let member = "";
31
+ let ntok = "";
32
+
33
+ const get = (path: string, headers: Record<string, string> = {}) => fetch(`${BASE}${path}`, { headers });
34
+ const auth = (token: string, ua?: string) => ({ Authorization: `Bearer ${token}`, ...(ua === undefined ? {} : { "User-Agent": ua }) });
35
+ async function byCaller(): Promise<{ text: string; rows: any[] }> {
36
+ const res = await get("/api/stats/routes?minutes=5&by=caller", auth(admin));
37
+ expect(res.status).toBe(200);
38
+ const text = await res.text();
39
+ return { text, rows: JSON.parse(text).routes };
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ process.env.HOST = "127.0.0.1";
44
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
45
+ const { db } = await import("./db.js");
46
+ adminName = `rc_admin_${Date.now()}`;
47
+ const a = register(adminName, PW);
48
+ admin = a.token!;
49
+ adminId = a.user!.user_id;
50
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
51
+ member = register(`rc_member_${Date.now()}`, PW).token!;
52
+ const minted = createNetworkTokenForNode(adminId, a.network_id!, NODE_NAME, NODE_ID);
53
+ expect(minted.ok).toBe(true);
54
+ ntok = minted.token!;
55
+ const mod: any = await import("./server.js");
56
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ BASE = `http://127.0.0.1:${hub.port}`;
58
+ }, 30_000);
59
+
60
+ afterAll(() => {
61
+ try { hub?.stop?.(true); } catch {}
62
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
63
+ });
64
+
65
+ describe("GET /api/stats/routes?by=caller", () => {
66
+ test("without by=caller the body is exactly the pre-existing routeStats() output", async () => {
67
+ await get("/api/status", auth(admin, "node"));
68
+ await get("/api/status?light=1", auth(ntok, "agent-node/2.5.0-preview.88"));
69
+ // The stats request is recorded only after its body is built, so nothing lands between these two.
70
+ const expected = JSON.stringify({ ok: true, ...routeStats(5 * 60_000) });
71
+ const res = await get("/api/stats/routes?minutes=5", auth(admin));
72
+ expect(await res.text()).toBe(expected);
73
+ const body = JSON.parse(expected);
74
+ expect(Object.keys(body)).toEqual(["ok", "window_ms", "samples", "oldest_at", "routes"]);
75
+ for (const r of body.routes) expect(Object.keys(r)).toEqual(["route", "count", "total_ms", "avg_ms", "p95_ms", "max_ms", "bytes", "errors"]);
76
+ });
77
+
78
+ test("callers are counted per class: token kind + UA family", async () => {
79
+ const before = (await byCaller()).rows.find(r => r.route === "GET /api/status")?.callers ?? [];
80
+ const count = (cs: any[], cls: string) => cs.find(c => c.class === cls)?.count ?? 0;
81
+ for (let i = 0; i < 3; i++) expect((await get("/api/status", auth(member, "node"))).status).toBe(200);
82
+ await get("/api/status", auth(ntok, `agent-node/2.5.0-preview.88 (${UA_MARKER})`));
83
+ await get("/api/status", auth(member, `Mozilla/5.0 (X11; ${UA_MARKER}) Chrome/130.0`));
84
+ await get("/api/status", { "User-Agent": "curl/8.5.0" }); // anonymous → 401, still counted
85
+ const row = (await byCaller()).rows.find(r => r.route === "GET /api/status")!;
86
+ const callers = row.callers as any[];
87
+ expect(count(callers, "user node") - count(before, "user node")).toBe(3);
88
+ expect(count(callers, "node agent-node/2.5.0-preview.88") - count(before, "node agent-node/2.5.0-preview.88")).toBe(1);
89
+ expect(count(callers, "user browser") - count(before, "user browser")).toBe(1);
90
+ expect(count(callers, "anon curl/8.5.0") - count(before, "anon curl/8.5.0")).toBe(1);
91
+ expect(callers.reduce((n: number, c: any) => n + c.count, 0)).toBe(row.count);
92
+ expect(callers.reduce((n: number, c: any) => n + c.bytes, 0)).toBe(row.bytes);
93
+ for (const c of callers) expect(Object.keys(c)).toEqual(["class", "count", "bytes"]);
94
+ });
95
+
96
+ test("no token, id, username or raw User-Agent text in the output", async () => {
97
+ await get(`/api/status?token=${ntok}`, { "User-Agent": `${UA_MARKER}/1.0` });
98
+ const { text, rows } = await byCaller();
99
+ for (const secret of [admin, member, ntok, adminId, adminName, NODE_ID, NODE_NAME, UA_MARKER, "Mozilla", "X11", "127.0.0.1", "ntok_", "utok_"]) {
100
+ expect(text).not.toContain(secret);
101
+ }
102
+ const classes = rows.flatMap(r => r.callers.map((c: any) => c.class));
103
+ expect(classes).toContain("node other");
104
+ for (const cls of classes) expect(cls).toMatch(/^(\(other\)|(node|user|master|token|anon) [a-z0-9._-]+(\/[0-9A-Za-z.-]+)?)$/);
105
+ });
106
+
107
+ test("distinct classes per route are capped, the rest folded into (other)", async () => {
108
+ const n = ROUTE_MAX_CALLERS_PER_ROUTE + 5;
109
+ for (let i = 0; i < n; i++) await get("/api/networks", auth(member, `curl/${i}.0.0`));
110
+ const row = (await byCaller()).rows.find(r => r.route === "GET /api/networks")!;
111
+ expect(row.callers.length).toBe(ROUTE_MAX_CALLERS_PER_ROUTE);
112
+ expect(row.callers.at(-1).class).toBe(ROUTE_OVERFLOW_KEY);
113
+ expect(row.callers.reduce((s: number, c: any) => s + c.count, 0)).toBe(row.count);
114
+ });
115
+
116
+ test("still admin-only", async () => {
117
+ expect((await get("/api/stats/routes?by=caller", auth(member))).status).toBe(403);
118
+ expect((await get("/api/stats/routes?by=caller", auth(ntok))).status).toBe(403);
119
+ });
120
+ });
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import {
3
- __resetRouteTimingForTest, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
3
+ __resetRouteTimingForTest, callerTokenKind, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
4
+ setRouteCallerMasterToken, setRouteCallerTokenReader, userAgentFamily, ROUTE_MAX_CALLERS_PER_ROUTE,
4
5
  ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
5
6
  } from "./route-timing";
6
7
 
@@ -135,3 +136,76 @@ test("mcpRouteLabel: method, tool name, and nothing else", () => {
135
136
  expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
136
137
  expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
137
138
  });
139
+
140
+ // ── caller classes (?by=caller) ──
141
+ const reqAs = (path: string, headers: Record<string, string>) => new Request(`http://hub${path}`, { headers });
142
+
143
+ test("userAgentFamily keeps only allow-listed product names and a sanitized version", () => {
144
+ expect(userAgentFamily("agent-node/2.5.0-preview.88")).toBe("agent-node/2.5.0-preview.88");
145
+ expect(userAgentFamily("node")).toBe("node");
146
+ expect(userAgentFamily("Bun/1.2.19")).toBe("bun/1.2.19");
147
+ expect(userAgentFamily("okhttp/4.12.0")).toBe("okhttp/4.12.0");
148
+ expect(userAgentFamily("curl/8.5.0")).toBe("curl/8.5.0");
149
+ expect(userAgentFamily("agent-network-desktop/0.2.170")).toBe("agent-network-desktop/0.2.170");
150
+ expect(userAgentFamily("tauri-plugin-http/2.5.9")).toBe("tauri-plugin-http/2.5.9");
151
+ expect(userAgentFamily("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15")).toBe("browser");
152
+ expect(userAgentFamily("SomeApp/42 CFNetwork/1568.100.1 Darwin/24.0.0")).toBe("cfnetwork/1568.100.1");
153
+ expect(userAgentFamily(null)).toBe("none");
154
+ expect(userAgentFamily(" ")).toBe("none");
155
+ // Free text never comes through: unknown product names, and anything after a version.
156
+ expect(userAgentFamily("my-laptop-hostname/1.0")).toBe("other");
157
+ expect(userAgentFamily("curl/8.5.0;user=alice@example.com")).toBe("curl/8.5.0");
158
+ expect(userAgentFamily("node/abc-secret")).toBe("node");
159
+ });
160
+
161
+ test("callerTokenKind recognises token kinds without keeping the token", () => {
162
+ setRouteCallerMasterToken("master-secret-xyz");
163
+ // Header-only reader, as installed by default; another test file in the same process may have booted server.ts.
164
+ const previous = setRouteCallerTokenReader(r => r.headers.get("authorization")?.replace("Bearer ", "") || "");
165
+ try {
166
+ const kind = (headers: Record<string, string>) => callerTokenKind(reqAs("/api/status", headers));
167
+ expect(kind({ Authorization: "Bearer ntok_abc" })).toBe("node");
168
+ expect(kind({ Authorization: "Bearer utok_abc" })).toBe("user");
169
+ expect(kind({ Authorization: "Bearer master-secret-xyz" })).toBe("master");
170
+ expect(kind({ Authorization: "Bearer something-else" })).toBe("token");
171
+ expect(kind({})).toBe("anon");
172
+ // ?token= is read only through the reader server.ts installs (requestToken); a header-only reader ignores it.
173
+ expect(callerTokenKind(reqAs("/events/x?token=ntok_q", {}))).toBe("anon");
174
+ setRouteCallerTokenReader(r => new URL(r.url).searchParams.get("t") || "");
175
+ expect(callerTokenKind(reqAs("/events/x?t=ntok_q", {}))).toBe("node");
176
+ } finally {
177
+ setRouteCallerMasterToken(undefined);
178
+ setRouteCallerTokenReader(previous);
179
+ }
180
+ });
181
+
182
+ test("routeStats({byCaller}) counts per class; without it the rows have no callers field", () => {
183
+ __resetRouteTimingForTest();
184
+ const now = 3_000_000_000;
185
+ const status = (headers: Record<string, string>, len: number) => recordRouteTiming(reqAs("/api/status", headers), at(1), res(200, len), now);
186
+ for (let i = 0; i < 3; i++) status({ Authorization: "Bearer utok_secret1", "User-Agent": "node" }, 100);
187
+ status({ Authorization: "Bearer ntok_secret2", "User-Agent": "agent-node/2.5.0-preview.88" }, 10);
188
+ status({ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0" }, 1);
189
+ const plain = routeStats(60_000, now);
190
+ expect(plain.routes[0]).not.toHaveProperty("callers");
191
+ const by = routeStats(60_000, now, { byCaller: true });
192
+ expect(by.routes[0].callers).toEqual([
193
+ { class: "user node", count: 3, bytes: 300 },
194
+ { class: "node agent-node/2.5.0-preview.88", count: 1, bytes: 10 },
195
+ { class: "anon browser", count: 1, bytes: 1 },
196
+ ]);
197
+ expect(JSON.stringify(by)).not.toMatch(/secret|Mozilla|Linux|Chrome/);
198
+ });
199
+
200
+ test("caller classes are capped per route: per bucket and again after merging buckets", () => {
201
+ __resetRouteTimingForTest();
202
+ const now = 4_000_000_000 - (4_000_000_000 % ROUTE_BUCKET_MS);
203
+ // Minute 1: 30 distinct versions → 20 stored, 10 into (other). Minute 2: 25 more distinct ones.
204
+ for (let i = 0; i < 30; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now - ROUTE_BUCKET_MS);
205
+ for (let i = 100; i < 125; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now);
206
+ const callers = routeStats(5 * 60_000, now, { byCaller: true }).routes[0].callers!;
207
+ expect(callers.length).toBe(ROUTE_MAX_CALLERS_PER_ROUTE);
208
+ expect(callers.at(-1)!.class).toBe(ROUTE_OVERFLOW_KEY);
209
+ expect(callers.reduce((n, c) => n + c.count, 0)).toBe(55);
210
+ expect(callers.reduce((n, c) => n + c.bytes, 0)).toBe(55);
211
+ });
@@ -1,6 +1,6 @@
1
1
  // 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
2
2
  // 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
3
- // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者、不记请求体。
3
+ // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记请求体;调用者只记粗粒度类别(见下文 callerClass)。
4
4
  //
5
5
  // 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
6
6
  // 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
@@ -20,7 +20,8 @@ function histBin(ms: number): number {
20
20
  }
21
21
  const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
22
22
 
23
- type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array };
23
+ type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array; callers?: Map<string, CallerAgg> };
24
+ type CallerAgg = { count: number; bytes: number };
24
25
  type Bucket = { minute: number; routes: Map<string, Agg> };
25
26
  // 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
26
27
  const buckets: Bucket[] = [];
@@ -60,6 +61,77 @@ export function mcpRouteLabel(body: unknown): string {
60
61
  return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
61
62
  }
62
63
 
64
+ // ── 调用方分类(2026-10-01)────────────────────────────────────────────────────────────
65
+ // 生产上全量 GET /api/status 每秒 ~2.8 次,但大多数连接经 frp 隧道进来,来源全是 127.0.0.1,也没有访问日志,
66
+ // 看不出是谁在拉。每个请求再记一个粗粒度的「调用方类别」= 令牌种类 + User-Agent 家族,
67
+ // GET /api/stats/routes?by=caller 按路由给出各类别的次数 / 字节。
68
+ // 🔴 只记类别:不记令牌、用户 / 节点 id、IP、完整 UA。UA 只认白名单里的产品名(其余一律 "other"),
69
+ // 版本号只放行数字 + 一段预发布标记,浏览器一律折成 "browser"。
70
+ /** 每个分钟桶里每个路由最多这么多个调用方类别;再多的记到 ROUTE_OVERFLOW_KEY。汇总输出也只给前这么多个。 */
71
+ export const ROUTE_MAX_CALLERS_PER_ROUTE = 20;
72
+
73
+ let masterToken = "";
74
+ /** 启动时把 COMMHUB_AUTH_TOKEN 交进来,用于把它认成 "master";只做相等比较,不输出。 */
75
+ export function setRouteCallerMasterToken(token: string | undefined): void {
76
+ masterToken = token || "";
77
+ }
78
+
79
+ // 取令牌走 server.ts 的 requestToken()(启动时交进来),与鉴权同一个优先级:先 Authorization,再 ?token=。
80
+ // 这里不自己读 ?token=(query-token-ratchet 只许减少直接读的地方)。没交进来时只看 Authorization 头。
81
+ let readToken: (req: Request) => string = req => req.headers.get("authorization")?.replace("Bearer ", "") || "";
82
+ /** 返回原来的读取函数(测试用来还原)。 */
83
+ export function setRouteCallerTokenReader(reader: (req: Request) => string): (req: Request) => string {
84
+ const previous = readToken;
85
+ readToken = reader;
86
+ return previous;
87
+ }
88
+
89
+ /** 令牌种类:ntok_ → node,utok_ → user,等于 master 令牌 → master,别的非空令牌 → token,没有 → anon。 */
90
+ export function callerTokenKind(req: Request): string {
91
+ const token = readToken(req);
92
+ if (!token) return "anon";
93
+ if (token.startsWith("ntok_")) return "node";
94
+ if (token.startsWith("utok_")) return "user";
95
+ if (masterToken && token === masterToken) return "master";
96
+ return "token";
97
+ }
98
+
99
+ // UA 的第一个产品名(小写)→ 输出用的名字。只有这里列出的名字会原样出现在统计里。
100
+ const UA_PRODUCTS = new Map([
101
+ "agent-node", "agent-network", "anet", "agent-network-desktop", "agent-network-dashboard", "commhub-server", "tauri-plugin-http",
102
+ "claude-code", "node", "undici", "node-fetch", "axios", "got", "bun", "deno", "okhttp", "cfnetwork", "dalvik",
103
+ "curl", "wget", "python-requests", "python-httpx", "python-urllib", "aiohttp", "go-http-client", "reqwest",
104
+ "postmanruntime", "httpie", "powershell",
105
+ ].map(n => [n, n]));
106
+ const UA_HEAD_RE = /^([A-Za-z][A-Za-z0-9._-]{0,40})(?:\/(\S{1,64}))?/;
107
+ const UA_VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,2}(?:-[A-Za-z]{1,16}(?:\.\d{1,6})?)?/;
108
+
109
+ /**
110
+ * User-Agent 家族:"agent-node/2.5.0-preview.88"、"node"、"Bun/1.2.3" → "bun/1.2.3"、"okhttp/4.12.0"、
111
+ * "curl/8.5.0";Mozilla/… → "browser";没有 UA → "none";不在白名单 → "other"。
112
+ * iOS 的 RN fetch 形如 "<App>/<build> CFNetwork/… Darwin/…",第一个产品名是 app 自己的,认 CFNetwork 记成 "cfnetwork"。
113
+ */
114
+ export function userAgentFamily(ua: string | null): string {
115
+ const s = (ua || "").trim();
116
+ if (!s) return "none";
117
+ if (s.startsWith("Mozilla/")) return "browser";
118
+ const head = UA_HEAD_RE.exec(s);
119
+ let name = head ? UA_PRODUCTS.get(head[1].toLowerCase()) : undefined;
120
+ let rawVersion = head?.[2];
121
+ if (!name) {
122
+ if (!/\sCFNetwork\//.test(s)) return "other";
123
+ name = "cfnetwork";
124
+ rawVersion = /\sCFNetwork\/(\S{1,64})/.exec(s)?.[1];
125
+ }
126
+ const version = rawVersion ? UA_VERSION_RE.exec(rawVersion)?.[0] : undefined;
127
+ return version ? `${name}/${version}` : name;
128
+ }
129
+
130
+ /** 一个请求的调用方类别,如 "node agent-node/2.5.0-preview.88"、"user browser"、"anon none"。 */
131
+ export function callerClass(req: Request): string {
132
+ return `${callerTokenKind(req)} ${userAgentFamily(req.headers.get("user-agent"))}`;
133
+ }
134
+
63
135
  /** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
64
136
  export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
65
137
  if (!res) return res;
@@ -69,11 +141,11 @@ export function recordRouteTiming<T extends Response | undefined>(req: Request,
69
141
  const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
70
142
  const length = Number(res.headers.get("content-length"));
71
143
  const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
72
- addSample(route, performance.now() - started, res.status, bytes, now);
144
+ addSample(route, performance.now() - started, res.status, bytes, now, callerClass(req));
73
145
  return res;
74
146
  }
75
147
 
76
- function addSample(route: string, ms: number, status: number, bytes: number, now: number): void {
148
+ function addSample(route: string, ms: number, status: number, bytes: number, now: number, caller: string): void {
77
149
  const minute = Math.floor(now / ROUTE_BUCKET_MS);
78
150
  let bucket = buckets[buckets.length - 1];
79
151
  if (!bucket || minute > bucket.minute) {
@@ -102,15 +174,25 @@ function addSample(route: string, ms: number, status: number, bytes: number, now
102
174
  agg.bytes += bytes;
103
175
  if (status >= 500) agg.errors++;
104
176
  agg.hist[histBin(ms)]++;
177
+ const callers = agg.callers ??= new Map();
178
+ let c = callers.get(caller);
179
+ if (!c) {
180
+ if (callers.size >= ROUTE_MAX_CALLERS_PER_ROUTE) caller = ROUTE_OVERFLOW_KEY;
181
+ c = callers.get(caller);
182
+ if (!c) callers.set(caller, c = { count: 0, bytes: 0 });
183
+ }
184
+ c.count++;
185
+ c.bytes += bytes;
105
186
  }
106
187
 
107
- export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
188
+ export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number; callers?: CallerStat[] };
189
+ export type CallerStat = { class: string; count: number; bytes: number };
108
190
 
109
191
  /**
110
192
  * 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
111
193
  * oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
112
194
  */
113
- export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
195
+ export function routeStats(windowMs: number, now: number = Date.now(), opts: { byCaller?: boolean } = {}): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
114
196
  const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
115
197
  const merged = new Map<string, Agg>();
116
198
  let oldest: number | null = null;
@@ -126,6 +208,13 @@ export function routeStats(windowMs: number, now: number = Date.now()): { window
126
208
  m.bytes += a.bytes;
127
209
  m.errors += a.errors;
128
210
  for (let i = 0; i < HIST_BINS; i++) m.hist[i] += a.hist[i];
211
+ if (opts.byCaller && a.callers) {
212
+ const mc = m.callers ??= new Map();
213
+ for (const [cls, c] of a.callers) {
214
+ const t = mc.get(cls);
215
+ if (t) { t.count += c.count; t.bytes += c.bytes; } else mc.set(cls, { count: c.count, bytes: c.bytes });
216
+ }
217
+ }
129
218
  }
130
219
  }
131
220
  const round = (n: number) => Math.round(n * 10) / 10;
@@ -142,11 +231,27 @@ export function routeStats(windowMs: number, now: number = Date.now()): { window
142
231
  max_ms: round(a.maxMs),
143
232
  bytes: a.bytes,
144
233
  errors: a.errors,
234
+ ...(opts.byCaller ? { callers: callerStats(a.callers) } : {}),
145
235
  };
146
236
  }).sort((a, b) => b.total_ms - a.total_ms);
147
237
  return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
148
238
  }
149
239
 
240
+ /** 按次数降序;各分钟桶的类别合起来可能超过上限,超出的并进 ROUTE_OVERFLOW_KEY。 */
241
+ function callerStats(callers: Map<string, CallerAgg> | undefined): CallerStat[] {
242
+ let other: CallerStat | undefined;
243
+ const named: CallerStat[] = [];
244
+ for (const [cls, c] of callers ?? []) {
245
+ if (cls === ROUTE_OVERFLOW_KEY) other = { class: cls, count: c.count, bytes: c.bytes };
246
+ else named.push({ class: cls, count: c.count, bytes: c.bytes });
247
+ }
248
+ named.sort((a, b) => b.count - a.count || b.bytes - a.bytes || (a.class < b.class ? -1 : 1));
249
+ if (!other && named.length <= ROUTE_MAX_CALLERS_PER_ROUTE) return named;
250
+ other ??= { class: ROUTE_OVERFLOW_KEY, count: 0, bytes: 0 };
251
+ for (const r of named.splice(ROUTE_MAX_CALLERS_PER_ROUTE - 1)) { other.count += r.count; other.bytes += r.bytes; }
252
+ return [...named, other];
253
+ }
254
+
150
255
  /** Test-only. */
151
256
  export function __resetRouteTimingForTest(): void {
152
257
  buckets.length = 0;
package/src/server.ts CHANGED
@@ -61,7 +61,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
61
61
  import { diagnoseTask } from "./task-diagnostic.js";
62
62
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
63
63
  import { handleRequirementsRequest } from "./requirements.js";
64
- import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats } from "./route-timing.js";
64
+ import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
65
65
  import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
66
66
  import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
67
67
  import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
@@ -72,6 +72,8 @@ import { parseHubTimestamp } from "./hub-timestamp";
72
72
  const PORT = resolvePort(process.env.PORT);
73
73
  const HOST = process.env.HOST || "127.0.0.1";
74
74
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
75
+ setRouteCallerMasterToken(AUTH_TOKEN);
76
+ setRouteCallerTokenReader(req => requestToken(req)); // 函数声明会提升;与鉴权同一个取令牌优先级
75
77
  const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
76
78
  const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
77
79
  const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
@@ -2154,12 +2156,14 @@ return Bun.serve({
2154
2156
 
2155
2157
  // ── REST: per-route timing (ops-only, same gate as /api/stats/sse) ──
2156
2158
  // ?minutes=N(默认 15,最多 1440):最近 N 分钟里每个路由的次数、总耗时、p95、最大、字节。
2159
+ // &by=caller:每个路由再带 callers —— 令牌种类 + UA 家族的次数 / 字节(route-timing.ts callerClass)。不带时输出逐字节不变。
2157
2160
  if (url.pathname === "/api/stats/routes" && req.method === "GET") {
2158
2161
  if (restAuth && !isAdmin) {
2159
2162
  return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
2160
2163
  }
2161
2164
  const minutes = Math.min(Math.max(Number(url.searchParams.get("minutes")) || 15, 1), 1440);
2162
- return withCors(req, Response.json({ ok: true, ...routeStats(minutes * 60_000) }));
2165
+ const byCaller = url.searchParams.get("by") === "caller";
2166
+ return withCors(req, Response.json({ ok: true, ...routeStats(minutes * 60_000, Date.now(), { byCaller }) }));
2163
2167
  }
2164
2168
 
2165
2169
  // ── #473 REST: SSE connection detail (ops-only) ──