@sleep2agi/commhub-server 0.9.0-preview.77 → 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.77",
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
+ // POST /mcp in /api/stats/routes carries the JSON-RPC method and, for tools/call, the tool name
2
+ // ("POST /mcp tools/call report_status"). Production showed POST /mcp as the biggest route (15 req/s,
3
+ // ~13% of a core) with no way to tell which tool. This suite pins, over real HTTP:
4
+ // - the labels (tools/call <name>, tools/list, batch) and that MCP responses are unchanged in kind;
5
+ // - arguments never reach the stats (a marker in arguments / a hostile tool name never shows up);
6
+ // - odd bodies still get the transport's own answers (invalid JSON → -32700, oversized → parsed by
7
+ // the transport itself) and are labelled "?";
8
+ // - unauthenticated /mcp stays plain "POST /mcp" (the body is never read before auth).
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
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-mcp-route-label-"));
16
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
17
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
18
+
19
+ const PW = "McpRouteLabelPassw0rd!";
20
+ const MARKER = "zz-argument-marker-7f3a";
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ let admin = "";
24
+
25
+ const MCP_HEADERS = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" };
26
+ async function mcpRaw(token: string, body: string): Promise<{ status: number; text: string }> {
27
+ const res = await fetch(`${BASE}/mcp`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...MCP_HEADERS }, body });
28
+ return { status: res.status, text: await res.text() };
29
+ }
30
+ function rpc(text: string): any {
31
+ const lines = text.split("\n").filter(x => x.startsWith("data:"));
32
+ return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(text);
33
+ }
34
+ async function routes(): Promise<{ text: string; keys: string[]; rows: any[] }> {
35
+ const res = await fetch(`${BASE}/api/stats/routes?minutes=5`, { headers: { Authorization: `Bearer ${admin}` } });
36
+ const text = await res.text();
37
+ const rows = JSON.parse(text).routes as any[];
38
+ return { text, keys: rows.map(r => r.route), rows };
39
+ }
40
+
41
+ beforeAll(async () => {
42
+ process.env.HOST = "127.0.0.1";
43
+ const { register } = await import("./auth.js");
44
+ const { db } = await import("./db.js");
45
+ const a = register(`mrl_admin_${Date.now()}`, PW);
46
+ admin = a.token!;
47
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
48
+ const mod: any = await import("./server.js");
49
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
50
+ BASE = `http://127.0.0.1:${hub.port}`;
51
+ }, 30_000);
52
+
53
+ afterAll(() => {
54
+ try { hub?.stop?.(true); } catch {}
55
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
56
+ });
57
+
58
+ describe("POST /mcp route label", () => {
59
+ test("tools/call is labelled with the tool name and still answers normally", async () => {
60
+ const r = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_all_status", arguments: { note: MARKER } } }));
61
+ expect(r.status).toBe(200);
62
+ expect(rpc(r.text).result).toBeDefined();
63
+ const { keys } = await routes();
64
+ expect(keys).toContain("POST /mcp tools/call get_all_status");
65
+ });
66
+
67
+ test("tools/list and a batch get their own labels", async () => {
68
+ const list = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }));
69
+ expect(list.status).toBe(200);
70
+ expect(rpc(list.text).result.tools.length).toBeGreaterThan(10);
71
+ await mcpRaw(admin, JSON.stringify([{ jsonrpc: "2.0", id: 3, method: "tools/list" }]));
72
+ const { keys } = await routes();
73
+ expect(keys).toContain("POST /mcp tools/list");
74
+ expect(keys).toContain("POST /mcp batch");
75
+ });
76
+
77
+ test("arguments and hostile tool names never reach the stats", async () => {
78
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: `x ${MARKER}`, arguments: { a: MARKER } } }));
79
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "n".repeat(500), arguments: {} } }));
80
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 6, method: `tools/${MARKER} with spaces` }));
81
+ const { text, keys } = await routes();
82
+ expect(text).not.toContain(MARKER);
83
+ expect(text).not.toContain("n".repeat(65));
84
+ expect(keys).toContain("POST /mcp tools/call ?");
85
+ expect(keys).toContain("POST /mcp ?");
86
+ for (const k of keys) expect(k.length).toBeLessThan(200);
87
+ });
88
+
89
+ test("invalid JSON still gets the transport's parse error, labelled ?", async () => {
90
+ const r = await mcpRaw(admin, `{"jsonrpc":"2.0","method":"tools/list",`);
91
+ expect(r.status).toBe(400);
92
+ expect(rpc(r.text).error.code).toBe(-32700);
93
+ expect(rpc(r.text).error.message).toBe("Parse error: Invalid JSON");
94
+ const notRpc = await mcpRaw(admin, "null");
95
+ expect(notRpc.status).toBe(400);
96
+ expect(rpc(notRpc.text).error.code).toBe(-32700);
97
+ });
98
+
99
+ test("a body over the label limit is left to the transport and still works, labelled ?", async () => {
100
+ const { __resetRouteTimingForTest } = await import("./route-timing.js");
101
+ __resetRouteTimingForTest();
102
+ const padded = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }).replace("{", "{" + " ".repeat(1024 * 1024 + 10));
103
+ const r = await mcpRaw(admin, padded);
104
+ expect(r.status).toBe(200);
105
+ expect(rpc(r.text).result.tools.length).toBeGreaterThan(10);
106
+ const { keys } = await routes();
107
+ expect(keys).toContain("POST /mcp ?");
108
+ expect(keys).not.toContain("POST /mcp tools/list");
109
+ });
110
+
111
+ test("unauthenticated /mcp is refused as before and not labelled", async () => {
112
+ const { __resetRouteTimingForTest } = await import("./route-timing.js");
113
+ __resetRouteTimingForTest();
114
+ const r = await mcpRaw("", JSON.stringify({ jsonrpc: "2.0", id: 8, method: "tools/list" }));
115
+ expect(r.status).toBe(401);
116
+ const { keys } = await routes();
117
+ expect(keys).toContain("POST /mcp");
118
+ expect(keys.filter(k => k.startsWith("POST /mcp "))).toEqual([]);
119
+ });
120
+ });
@@ -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,7 +1,14 @@
1
1
  import { expect, test } from "bun:test";
2
- import { __resetRouteTimingForTest, recordRouteTiming, routeKey, routeStats, ROUTE_RING_SIZE } from "./route-timing";
2
+ import {
3
+ __resetRouteTimingForTest, callerTokenKind, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
4
+ setRouteCallerMasterToken, setRouteCallerTokenReader, userAgentFamily, ROUTE_MAX_CALLERS_PER_ROUTE,
5
+ ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
6
+ } from "./route-timing";
3
7
 
4
8
  const u = (s: string) => new URL(`http://hub${s}`);
9
+ const req = (path: string, method = "GET") => new Request(`http://hub${path}`, { method });
10
+ const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
11
+ const at = (ms: number) => performance.now() - ms;
5
12
 
6
13
  test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
7
14
  expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
@@ -13,12 +20,9 @@ test("routeKey collapses ids, aliases and file names, keeps payload-shaping para
13
20
  expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
14
21
  });
15
22
 
16
- test("routeStats groups the window, sorts by total time, and the ring keeps only the newest samples", () => {
23
+ test("routeStats groups the window, sorts by total time, and counts every request (no sample cap)", () => {
17
24
  __resetRouteTimingForTest();
18
- const now = 1_000_000;
19
- const req = (path: string) => new Request(`http://hub${path}`);
20
- const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
21
- const at = (ms: number) => performance.now() - ms;
25
+ const now = 1_000_000_000;
22
26
  recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
23
27
  recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
24
28
  recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
@@ -32,9 +36,176 @@ test("routeStats groups the window, sorts by total time, and the ring keeps only
32
36
  expect(r0.bytes).toBe(1500);
33
37
  expect(r0.max_ms).toBeGreaterThanOrEqual(29);
34
38
  expect(r0.total_ms).toBeGreaterThanOrEqual(39);
39
+ expect(r0.p95_ms).toBeLessThanOrEqual(r0.max_ms);
35
40
  expect(stats.routes[1].errors).toBe(1);
36
- for (let i = 0; i < ROUTE_RING_SIZE + 10; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
41
+ // The old 4096-sample ring dropped everything past its size; the buckets count all of it.
42
+ for (let i = 0; i < 10_000; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
37
43
  const after = routeStats(60_000, now);
38
- expect(after.samples).toBe(ROUTE_RING_SIZE);
39
- expect(after.routes.map(r => r.route)).toEqual(["GET /api/tasks"]);
44
+ expect(after.routes.find(r => r.route === "GET /api/tasks")!.count).toBe(10_000);
45
+ expect(after.samples).toBe(10_003);
46
+ });
47
+
48
+ test("?minutes=10 really covers 10 minutes: a steady 15 req/s stream is fully counted, older traffic is not", () => {
49
+ __resetRouteTimingForTest();
50
+ const t0 = 2_000_000_000 - (2_000_000_000 % ROUTE_BUCKET_MS); // bucket-aligned
51
+ // 20 minutes at 15 req/s — the old ring filled in ~4.5 minutes of this.
52
+ for (let s = 0; s < 20 * 60; s++) {
53
+ for (let k = 0; k < 15; k++) recordRouteTiming(req("/mcp", "POST"), at(1), res(), t0 + s * 1000);
54
+ }
55
+ const now = t0 + 20 * 60_000;
56
+ const ten = routeStats(10 * 60_000, now);
57
+ // window starts at t0+10min → the ten minute buckets t0+10 … t0+19
58
+ expect(ten.samples).toBe(10 * 60 * 15);
59
+ expect(ten.oldest_at).toBe(new Date(t0 + 10 * 60_000).toISOString());
60
+ expect(routeStats(20 * 60_000, now).samples).toBe(20 * 60 * 15);
61
+ expect(routeStats(60 * 60_000, now).samples).toBe(20 * 60 * 15);
62
+ });
63
+
64
+ test("buckets older than the retention are dropped", () => {
65
+ __resetRouteTimingForTest();
66
+ const now = 3_000_000_000;
67
+ const old = now - (ROUTE_BUCKET_RETENTION + 5) * ROUTE_BUCKET_MS;
68
+ recordRouteTiming(req("/api/old"), at(1), res(), old);
69
+ expect(routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, old).samples).toBe(1);
70
+ recordRouteTiming(req("/api/new"), at(1), res(), now);
71
+ const s = routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, now);
72
+ expect(s.routes.map(r => r.route)).toEqual(["GET /api/new"]);
73
+ });
74
+
75
+ test("p95 comes from the histogram: close to the true value and never above max", () => {
76
+ __resetRouteTimingForTest();
77
+ const now = 4_000_000_000;
78
+ // 96 fast (~1 ms) and 4 slow (~200 ms): p95 (the 96th of 100, same rank as the old ring's
79
+ // sorted[floor(n·0.95)]) must be in the fast band, max in the slow band.
80
+ for (let i = 0; i < 96; i++) recordRouteTiming(req("/api/p"), at(1), res(), now);
81
+ for (let i = 0; i < 4; i++) recordRouteTiming(req("/api/p"), at(200), res(), now);
82
+ const r = routeStats(60_000, now).routes[0];
83
+ expect(r.p95_ms).toBeGreaterThanOrEqual(1);
84
+ expect(r.p95_ms).toBeLessThan(5);
85
+ expect(r.max_ms).toBeGreaterThanOrEqual(200);
86
+ __resetRouteTimingForTest();
87
+ recordRouteTiming(req("/api/q"), at(3), res(), now);
88
+ const one = routeStats(60_000, now).routes[0];
89
+ expect(one.p95_ms).toBe(one.max_ms);
90
+ });
91
+
92
+ test("distinct routes per bucket are capped; the rest land in the overflow key", () => {
93
+ __resetRouteTimingForTest();
94
+ const now = 5_000_000_000;
95
+ for (let i = 0; i < ROUTE_MAX_KEYS_PER_BUCKET + 50; i++) {
96
+ const r = req("/mcp", "POST");
97
+ labelRoute(r, `tools/call t${String.fromCharCode(97 + (i % 26))}${Math.floor(i / 26)}x`);
98
+ recordRouteTiming(r, at(1), res(), now);
99
+ }
100
+ const s = routeStats(60_000, now);
101
+ expect(s.routes.length).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 1);
102
+ expect(s.routes.find(r => r.route === ROUTE_OVERFLOW_KEY)!.count).toBe(50);
103
+ expect(s.samples).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 50);
104
+ });
105
+
106
+ test("a label is appended to the route shape of that request only", () => {
107
+ __resetRouteTimingForTest();
108
+ const now = 6_000_000_000;
109
+ const a = req("/mcp", "POST");
110
+ labelRoute(a, "tools/call report_status");
111
+ recordRouteTiming(a, at(1), res(), now);
112
+ recordRouteTiming(req("/mcp", "POST"), at(1), res(), now);
113
+ expect(routeStats(60_000, now).routes.map(r => r.route).sort()).toEqual(["POST /mcp", "POST /mcp tools/call report_status"]);
114
+ });
115
+
116
+ test("mcpRouteLabel: method, tool name, and nothing else", () => {
117
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: { task: "secret" } } })).toBe("tools/call report_status");
118
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/list" })).toBe("tools/list");
119
+ expect(mcpRouteLabel({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe("notifications/initialized");
120
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "initialize", params: { name: "x" } })).toBe("initialize");
121
+ // odd shapes collapse to "?"
122
+ expect(mcpRouteLabel([{ method: "tools/list" }])).toBe("batch");
123
+ expect(mcpRouteLabel(null)).toBe("?");
124
+ expect(mcpRouteLabel("tools/list")).toBe("?");
125
+ expect(mcpRouteLabel(42)).toBe("?");
126
+ expect(mcpRouteLabel({})).toBe("?");
127
+ expect(mcpRouteLabel({ method: 7 })).toBe("?");
128
+ expect(mcpRouteLabel({ method: "x".repeat(65) })).toBe("?");
129
+ expect(mcpRouteLabel({ method: "tools/call x" })).toBe("?");
130
+ expect(mcpRouteLabel({ method: "通信" })).toBe("?");
131
+ expect(mcpRouteLabel({ method: "tools/call" })).toBe("tools/call ?");
132
+ expect(mcpRouteLabel({ method: "tools/call", params: "report_status" })).toBe("tools/call ?");
133
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: { toString: () => "x" } } })).toBe("tools/call ?");
134
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "a b" } })).toBe("tools/call ?");
135
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "../../etc/passwd" } })).toBe("tools/call ?");
136
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
137
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
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);
40
211
  });
@@ -1,11 +1,30 @@
1
- // 每个路由的耗时环(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
2
- // 说不清是谁在吃 CPU。这里在 fetch 入口记最近 ROUTE_RING_SIZE 个请求(路由形状、耗时、状态、字节),
3
- // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者。
4
- export const ROUTE_RING_SIZE = 4096;
1
+ // 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
2
+ // 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
3
+ // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记请求体;调用者只记粗粒度类别(见下文 callerClass)。
4
+ //
5
+ // 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
6
+ // 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
7
+ // 保留 ROUTE_BUCKET_RETENTION 个桶(24 小时),窗口多长就真覆盖多长。p95 从直方图取(桶上沿,不超过 max)。
8
+ export const ROUTE_BUCKET_MS = 60_000;
9
+ export const ROUTE_BUCKET_RETENTION = 1440;
10
+ /** 每个分钟桶里最多这么多个不同的路由;再多的记到 ROUTE_OVERFLOW_KEY,防止怪路径 / 怪工具名把内存撑大。 */
11
+ export const ROUTE_MAX_KEYS_PER_BUCKET = 256;
12
+ export const ROUTE_OVERFLOW_KEY = "(other)";
5
13
 
6
- type Sample = { at: number; route: string; ms: number; status: number; bytes: number | null };
7
- const ring: Sample[] = [];
8
- let next = 0;
14
+ // 耗时直方图:第 i 格的上沿 = HIST_BASE_MS · 2^(i/2),0.05 ms … ~52 s;最后一格收下更慢的。
15
+ const HIST_BASE_MS = 0.05;
16
+ const HIST_BINS = 41;
17
+ function histBin(ms: number): number {
18
+ if (!(ms > HIST_BASE_MS)) return 0;
19
+ return Math.min(HIST_BINS - 1, Math.ceil(2 * Math.log2(ms / HIST_BASE_MS)));
20
+ }
21
+ const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
22
+
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 };
25
+ type Bucket = { minute: number; routes: Map<string, Agg> };
26
+ // 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
27
+ const buckets: Bucket[] = [];
9
28
 
10
29
  /** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
11
30
  const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
@@ -18,53 +37,222 @@ export function routeKey(method: string, url: URL): string {
18
37
  return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
19
38
  }
20
39
 
40
+ // 处理器给某个请求补一段标签(比如 POST /mcp 的 JSON-RPC 方法名),recordRouteTiming 拼到路由形状后面。
41
+ const labels = new WeakMap<Request, string>();
42
+ export function labelRoute(req: Request, label: string): void {
43
+ labels.set(req, label);
44
+ }
45
+
46
+ const MCP_METHOD_RE = /^[A-Za-z][A-Za-z0-9_./-]{0,63}$/;
47
+ const MCP_TOOL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
48
+ /**
49
+ * POST /mcp 的标签:JSON-RPC 方法名,tools/call 再带工具名,如 "tools/call report_status"。
50
+ * 只读 method 和 params.name 两个字段,别的(尤其 arguments)一概不碰;形状不对的一律折成 "?",
51
+ * 批量请求记 "batch"。名字只放行短的 ASCII 标识符,防止拿请求体造出任意多、任意长的路由键。
52
+ */
53
+ export function mcpRouteLabel(body: unknown): string {
54
+ if (Array.isArray(body)) return "batch";
55
+ if (!body || typeof body !== "object") return "?";
56
+ const method = (body as { method?: unknown }).method;
57
+ if (typeof method !== "string" || !MCP_METHOD_RE.test(method)) return "?";
58
+ if (method !== "tools/call") return method;
59
+ const params = (body as { params?: unknown }).params;
60
+ const name = params && typeof params === "object" ? (params as { name?: unknown }).name : undefined;
61
+ return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
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
+
21
135
  /** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
22
136
  export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
23
137
  if (!res) return res;
24
138
  let url: URL;
25
139
  try { url = new URL(req.url); } catch { return res; }
140
+ const label = labels.get(req);
141
+ const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
26
142
  const length = Number(res.headers.get("content-length"));
27
- const sample: Sample = { at: now, route: routeKey(req.method, url), ms: performance.now() - started, status: res.status, bytes: Number.isFinite(length) && res.headers.has("content-length") ? length : null };
28
- if (ring.length < ROUTE_RING_SIZE) ring.push(sample);
29
- else ring[next] = sample;
30
- next = (next + 1) % ROUTE_RING_SIZE;
143
+ const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
144
+ addSample(route, performance.now() - started, res.status, bytes, now, callerClass(req));
31
145
  return res;
32
146
  }
33
147
 
34
- export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
148
+ function addSample(route: string, ms: number, status: number, bytes: number, now: number, caller: string): void {
149
+ const minute = Math.floor(now / ROUTE_BUCKET_MS);
150
+ let bucket = buckets[buckets.length - 1];
151
+ if (!bucket || minute > bucket.minute) {
152
+ buckets.push(bucket = { minute, routes: new Map() });
153
+ const oldest = minute - ROUTE_BUCKET_RETENTION + 1;
154
+ let drop = 0;
155
+ while (drop < buckets.length && buckets[drop].minute < oldest) drop++;
156
+ if (drop) buckets.splice(0, drop);
157
+ } else if (minute < bucket.minute) {
158
+ // 时间戳比最新的桶早(时钟回拨,或测试里乱序记):放回它自己的那一分钟;比保留期还早的丢掉。
159
+ if (minute < bucket.minute - ROUTE_BUCKET_RETENTION + 1) return;
160
+ let i = buckets.length - 1;
161
+ while (i > 0 && buckets[i - 1].minute >= minute) i--;
162
+ if (buckets[i].minute !== minute) buckets.splice(i, 0, { minute, routes: new Map() });
163
+ bucket = buckets[i];
164
+ }
165
+ let agg = bucket.routes.get(route);
166
+ if (!agg) {
167
+ if (bucket.routes.size >= ROUTE_MAX_KEYS_PER_BUCKET) route = ROUTE_OVERFLOW_KEY;
168
+ agg = bucket.routes.get(route);
169
+ if (!agg) bucket.routes.set(route, agg = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
170
+ }
171
+ agg.count++;
172
+ agg.totalMs += ms;
173
+ if (ms > agg.maxMs) agg.maxMs = ms;
174
+ agg.bytes += bytes;
175
+ if (status >= 500) agg.errors++;
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;
186
+ }
187
+
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 };
35
190
 
36
- /** 最近 windowMs 内的样本按路由汇总,按总耗时降序。 */
37
- export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
38
- const since = now - windowMs;
39
- const groups = new Map<string, Sample[]>();
191
+ /**
192
+ * 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
193
+ * oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
194
+ */
195
+ export function routeStats(windowMs: number, now: number = Date.now(), opts: { byCaller?: boolean } = {}): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
196
+ const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
197
+ const merged = new Map<string, Agg>();
40
198
  let oldest: number | null = null;
41
- for (const s of ring) {
42
- if (s.at < since) continue;
43
- if (oldest === null || s.at < oldest) oldest = s.at;
44
- let g = groups.get(s.route);
45
- if (!g) groups.set(s.route, g = []);
46
- g.push(s);
199
+ for (const bucket of buckets) {
200
+ if (bucket.minute < fromMinute) continue;
201
+ if (oldest === null) oldest = bucket.minute * ROUTE_BUCKET_MS;
202
+ for (const [route, a] of bucket.routes) {
203
+ let m = merged.get(route);
204
+ if (!m) merged.set(route, m = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
205
+ m.count += a.count;
206
+ m.totalMs += a.totalMs;
207
+ if (a.maxMs > m.maxMs) m.maxMs = a.maxMs;
208
+ m.bytes += a.bytes;
209
+ m.errors += a.errors;
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
+ }
218
+ }
47
219
  }
48
220
  const round = (n: number) => Math.round(n * 10) / 10;
49
- const routes = [...groups].map(([route, samples]): RouteStat => {
50
- const ms = samples.map(s => s.ms).sort((a, b) => a - b);
51
- const total = ms.reduce((a, b) => a + b, 0);
221
+ const routes = [...merged].map(([route, a]): RouteStat => {
222
+ const rank = Math.min(a.count, Math.floor(a.count * 0.95) + 1);
223
+ let seen = 0, bin = 0;
224
+ for (; bin < HIST_BINS - 1; bin++) { seen += a.hist[bin]; if (seen >= rank) break; }
52
225
  return {
53
226
  route,
54
- count: samples.length,
55
- total_ms: round(total),
56
- avg_ms: round(total / samples.length),
57
- p95_ms: round(ms[Math.min(ms.length - 1, Math.floor(ms.length * 0.95))]),
58
- max_ms: round(ms[ms.length - 1]),
59
- bytes: samples.reduce((n, s) => n + (s.bytes ?? 0), 0),
60
- errors: samples.filter(s => s.status >= 500).length,
227
+ count: a.count,
228
+ total_ms: round(a.totalMs),
229
+ avg_ms: round(a.totalMs / a.count),
230
+ p95_ms: round(Math.min(histUpper(bin), a.maxMs)),
231
+ max_ms: round(a.maxMs),
232
+ bytes: a.bytes,
233
+ errors: a.errors,
234
+ ...(opts.byCaller ? { callers: callerStats(a.callers) } : {}),
61
235
  };
62
236
  }).sort((a, b) => b.total_ms - a.total_ms);
63
237
  return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
64
238
  }
65
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
+
66
255
  /** Test-only. */
67
256
  export function __resetRouteTimingForTest(): void {
68
- ring.length = 0;
69
- next = 0;
257
+ buckets.length = 0;
70
258
  }
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 { 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";
@@ -103,6 +105,9 @@ if (AUTH_TOKEN) {
103
105
  console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
104
106
  }
105
107
 
108
+ // POST /mcp bodies up to this size are parsed once up front to label the route (see the /mcp handler).
109
+ const MCP_LABEL_MAX_BYTES = 1024 * 1024;
110
+
106
111
  // Read version from package.json so banners and /health stay in sync.
107
112
  const SERVER_VERSION = (() => {
108
113
  try {
@@ -111,6 +116,9 @@ const SERVER_VERSION = (() => {
111
116
  } catch { return "?"; }
112
117
  })();
113
118
 
119
+ // Feature flags advertised on /health (see the /health handler). Exported for tests.
120
+ export const HUB_HEALTH_CAPABILITIES = ["status_node_id"] as const;
121
+
114
122
  // In-memory log ring buffer — last N lines streamed via /api/server-logs.
115
123
  // Wraps console.log/info/warn/error so EVERY existing log call lands here
116
124
  // without source changes. Dashboard tails this buffer for "hub server log
@@ -941,9 +949,22 @@ return Bun.serve({
941
949
  const transport = new WebStandardStreamableHTTPServerTransport({
942
950
  sessionIdGenerator: undefined,
943
951
  });
952
+ // 2026-10-01 —— /api/stats/routes 里 POST /mcp 是最大的一项(生产 15 req/s),但看不出是哪个工具。
953
+ // 请求体我们自己读一次、解析一次,只取 method / params.name 做路由标签(mcpRouteLabel),
954
+ // 解析结果作为 parsedBody 交给 transport,不重复解析。没有 Content-Length 或超过 MCP_LABEL_MAX_BYTES
955
+ // 的请求体不碰,由 transport 照旧自己读。不是合法 JSON 时不传 parsedBody:transport 读已消费的
956
+ // body 失败,落进它自己的 catch,回的仍是原来那条 -32700「Parse error: Invalid JSON」(测试钉住)。
957
+ let mcpParsedBody: unknown;
958
+ if (req.method === "POST") {
959
+ const declared = Number(req.headers.get("content-length"));
960
+ if (req.headers.has("content-length") && Number.isFinite(declared) && declared <= MCP_LABEL_MAX_BYTES) {
961
+ try { mcpParsedBody = JSON.parse(await req.text()); } catch { mcpParsedBody = undefined; }
962
+ }
963
+ labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
964
+ }
944
965
  const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
945
966
  await mcpServer.connect(transport);
946
- const response = await transport.handleRequest(req);
967
+ const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
947
968
  // Disconnect after response to prevent McpServer leak
948
969
  setImmediate(() => mcpServer.close().catch(() => {}));
949
970
  // #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
@@ -2007,6 +2028,11 @@ return Bun.serve({
2007
2028
  max_upload_bytes: MAX_UPLOAD_BYTES,
2008
2029
  max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
2009
2030
  },
2031
+ // Public, stable feature flags for REST reads whose own response can't carry them.
2032
+ // status_node_id: GET /api/status accepts ?node_id=<id> (one node's rows; with light=1 the
2033
+ // row also carries node_id). The /api/status body stays byte-identical without the param, so
2034
+ // the flag lives here — agent-nodes already read /health once at boot.
2035
+ capabilities: HUB_HEALTH_CAPABILITIES,
2010
2036
  };
2011
2037
  if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
2012
2038
  return withCors(req, Response.json(body));
@@ -2130,12 +2156,14 @@ return Bun.serve({
2130
2156
 
2131
2157
  // ── REST: per-route timing (ops-only, same gate as /api/stats/sse) ──
2132
2158
  // ?minutes=N(默认 15,最多 1440):最近 N 分钟里每个路由的次数、总耗时、p95、最大、字节。
2159
+ // &by=caller:每个路由再带 callers —— 令牌种类 + UA 家族的次数 / 字节(route-timing.ts callerClass)。不带时输出逐字节不变。
2133
2160
  if (url.pathname === "/api/stats/routes" && req.method === "GET") {
2134
2161
  if (restAuth && !isAdmin) {
2135
2162
  return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
2136
2163
  }
2137
2164
  const minutes = Math.min(Math.max(Number(url.searchParams.get("minutes")) || 15, 1), 1440);
2138
- 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 }) }));
2139
2167
  }
2140
2168
 
2141
2169
  // ── #473 REST: SSE connection detail (ops-only) ──
@@ -2180,9 +2208,10 @@ return Bun.serve({
2180
2208
  // is unchanged so the dashboard / scripts that read full telemetry are
2181
2209
  // unaffected.
2182
2210
  const isLight = url.searchParams.get("light") === "1";
2211
+ const nodeIdFilter = url.searchParams.get("node_id")?.trim();
2183
2212
  const params: any[] = [];
2184
2213
  let sql = isLight
2185
- ? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
2214
+ ? `SELECT alias, status, agent, task, server, updated_at, network_id${nodeIdFilter ? ", node_id" : ""} FROM sessions WHERE 1=1`
2186
2215
  : `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
2187
2216
  sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
2188
2217
  // `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
@@ -2195,6 +2224,15 @@ return Bun.serve({
2195
2224
  params.push(aliasFilter);
2196
2225
  sql += ` AND alias = ?${params.length}`;
2197
2226
  }
2227
+ // `?node_id=<id>` narrows to one node's rows the same way (same scope, same projection). Every
2228
+ // agent-node resolves its own current alias every 30 s; unfiltered that is the whole network's
2229
+ // full projection (~91 KB gzip per call on production) to read one row. Advertised in /health
2230
+ // `capabilities` ("status_node_id") because old hubs ignore the parameter and — in the light
2231
+ // projection, which has no node_id — the caller could not tell its row from anyone else's.
2232
+ if (nodeIdFilter) {
2233
+ params.push(nodeIdFilter);
2234
+ sql += ` AND node_id = ?${params.length}`;
2235
+ }
2198
2236
  sql += " ORDER BY updated_at DESC";
2199
2237
  // `model` comes straight from the explicit sessions projection; `runtime` is
2200
2238
  // derived from the raw `agent` field. Both default to null for old nodes
@@ -2211,6 +2249,8 @@ return Bun.serve({
2211
2249
  updated_at: s.updated_at ?? null,
2212
2250
  runtime: normalizeRuntime(s.agent),
2213
2251
  network_id: s.network_id ?? null,
2252
+ // Only when filtering by node_id, so the unfiltered light response keeps its exact bytes.
2253
+ ...(nodeIdFilter ? { node_id: s.node_id ?? null } : {}),
2214
2254
  };
2215
2255
  }
2216
2256
  const externalSchedules = (() => {
@@ -0,0 +1,161 @@
1
+ // GET /api/status?node_id=<id> — one node's rows instead of the whole network.
2
+ // Every agent-node resolves its own current alias every 30 s (CurrentAliasResolver). Unfiltered that
3
+ // is the whole network's full projection (~91 KB gzip per call on production, ~90 nodes ⇒ ~3 req/s)
4
+ // to read one row. This suite pins that the filter narrows, never widens (it is exactly the caller's
5
+ // unfiltered rows with that node_id, for every kind of caller), that it combines with light=1 (which
6
+ // then carries node_id so the caller can check the row is its own), that an unknown id is an empty
7
+ // list rather than an error, that no/blank node_id is byte-identical to the old response, and that
8
+ // /health advertises it.
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register, createNetworkTokenForNode } from "./auth.js";
15
+ import { db } from "./db.js";
16
+
17
+ const DIR = mkdtempSync(join(tmpdir(), "anet-status-node-id-"));
18
+ let BASE = "";
19
+ let hub: any = null;
20
+ const PW = "StatusNodeIdPassw0rd!";
21
+ let aToken = "", aId = "", NET_A = "";
22
+ let bToken = "", bId = "", NET_B = "";
23
+ let nodeToken = "";
24
+
25
+ async function raw(token: string | null, path: string) {
26
+ const res = await fetch(`${BASE}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
27
+ return { status: res.status, text: await res.text() };
28
+ }
29
+ async function get(token: string, path: string) {
30
+ const r = await raw(token, path);
31
+ return { status: r.status, body: JSON.parse(r.text) as any };
32
+ }
33
+ function seed(alias: string, node: string, net: string, owner: string, minutesAgo: number) {
34
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, net, owner]);
35
+ db.run(
36
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, ?5, datetime('now', ?6), datetime('now'))`,
37
+ [`r_${node}`, alias, node, net, `task of ${alias}`, `-${minutesAgo} minutes`],
38
+ );
39
+ }
40
+ const nodeIds = (rows: any[]) => rows.map(r => r.node_id);
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
44
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
45
+ process.env.HOST = "127.0.0.1";
46
+ const a = register(`sn_owner_a_${Date.now()}`, PW);
47
+ aToken = a.token!; aId = a.user!.user_id; NET_A = a.network_id!;
48
+ const b = register(`sn_owner_b_${Date.now()}`, PW);
49
+ bToken = b.token!; bId = b.user!.user_id; NET_B = b.network_id!;
50
+ seed("sn-one", "node_sn_one", NET_A, aId, 1);
51
+ seed("sn-two", "node_sn_two", NET_A, aId, 2);
52
+ seed("sn-three", "node_sn_three", NET_A, aId, 3);
53
+ seed("sn-b-only", "node_sn_b_only", NET_B, bId, 0);
54
+ const minted = createNetworkTokenForNode(aId, NET_A, "sn-two", "node_sn_two");
55
+ nodeToken = minted.token!;
56
+ const mod: any = await import("./server.js");
57
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
58
+ BASE = `http://127.0.0.1:${hub.port}`;
59
+ }, 30_000);
60
+
61
+ afterAll(() => {
62
+ try { hub?.stop?.(true); } catch {}
63
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
64
+ });
65
+
66
+ describe("/api/status?node_id=", () => {
67
+ test("node_id narrows the full projection to that node", async () => {
68
+ const r = await get(aToken, `/api/status?network_id=${NET_A}&node_id=node_sn_two`);
69
+ expect(r.status).toBe(200);
70
+ expect(nodeIds(r.body.sessions)).toEqual(["node_sn_two"]);
71
+ expect(r.body.sessions[0].alias).toBe("sn-two");
72
+ expect(typeof r.body.sessions[0].rules_file_capable).toBe("boolean");
73
+ expect(r.body.sessions[0].host).toBeDefined();
74
+ expect(r.body.summary.total).toBe(1);
75
+ });
76
+
77
+ test("combines with light=1: light projection, one row, and it carries node_id", async () => {
78
+ const r = await get(aToken, `/api/status?light=1&network_id=${NET_A}&node_id=node_sn_three`);
79
+ expect(r.status).toBe(200);
80
+ expect(r.body.sessions).toHaveLength(1);
81
+ expect(r.body.sessions[0].alias).toBe("sn-three");
82
+ expect(r.body.sessions[0].node_id).toBe("node_sn_three");
83
+ expect(r.body.sessions[0].host).toBeUndefined();
84
+ expect(Object.keys(r.body.sessions[0]).sort()).toEqual(
85
+ ["agent", "alias", "network_id", "node_id", "runtime", "server", "status", "task", "updated_at"],
86
+ );
87
+ });
88
+
89
+ test("unknown id is an empty list with 200, not a 404", async () => {
90
+ for (const path of [`/api/status?network_id=${NET_A}&node_id=node_nobody`, `/api/status?light=1&node_id=node_nobody`]) {
91
+ const r = await get(aToken, path);
92
+ expect(r.status).toBe(200);
93
+ expect(r.body.ok).toBe(true);
94
+ expect(r.body.sessions).toEqual([]);
95
+ expect(r.body.summary.total).toBe(0);
96
+ }
97
+ });
98
+
99
+ test("absent or blank node_id: response bytes identical to the old read (full and light)", async () => {
100
+ for (const base of [`/api/status?network_id=${NET_A}`, `/api/status?light=1&network_id=${NET_A}`, `/api/status`]) {
101
+ const plain = await raw(aToken, base);
102
+ const blank = await raw(aToken, `${base}${base.includes("?") ? "&" : "?"}node_id=%20`);
103
+ expect(plain.status).toBe(200);
104
+ expect(blank.text).toBe(plain.text);
105
+ }
106
+ // the light rows without the param never gained a node_id field
107
+ const light = await get(aToken, `/api/status?light=1&network_id=${NET_A}`);
108
+ expect(light.body.sessions.length).toBe(3);
109
+ for (const s of light.body.sessions) expect("node_id" in s).toBe(false);
110
+ });
111
+
112
+ test("never widens: for every caller, filtered == that caller's unfiltered rows with that node_id", async () => {
113
+ const callers = [aToken, bToken, nodeToken];
114
+ const ids = ["node_sn_one", "node_sn_two", "node_sn_three", "node_sn_b_only", "node_nobody"];
115
+ for (const tok of callers) {
116
+ const full = await get(tok, `/api/status`);
117
+ expect(full.status).toBe(200);
118
+ for (const id of ids) {
119
+ const one = await get(tok, `/api/status?node_id=${id}`);
120
+ expect(one.status).toBe(full.status);
121
+ expect(one.body.sessions).toEqual(full.body.sessions.filter((s: any) => s.node_id === id));
122
+ }
123
+ }
124
+ });
125
+
126
+ test("a member of another network gets nothing for a node outside their network", async () => {
127
+ const r = await get(bToken, `/api/status?node_id=node_sn_one`);
128
+ expect(r.status).toBe(200);
129
+ expect(r.body.sessions).toEqual([]);
130
+ const own = await get(bToken, `/api/status?node_id=node_sn_b_only`);
131
+ expect(nodeIds(own.body.sessions)).toEqual(["node_sn_b_only"]);
132
+ });
133
+
134
+ test("a node token reads its own row (light) and nothing from another network", async () => {
135
+ const own = await get(nodeToken, `/api/status?light=1&node_id=node_sn_two`);
136
+ expect(own.status).toBe(200);
137
+ expect(own.body.sessions.map((s: any) => [s.node_id, s.alias])).toEqual([["node_sn_two", "sn-two"]]);
138
+ const foreign = await get(nodeToken, `/api/status?light=1&node_id=node_sn_b_only`);
139
+ expect(foreign.status).toBe(200);
140
+ expect(foreign.body.sessions).toEqual([]);
141
+ // explicit other network_id is refused exactly as without the filter
142
+ const cross = await raw(nodeToken, `/api/status?network_id=${NET_B}&node_id=node_sn_b_only`);
143
+ const crossNoFilter = await raw(nodeToken, `/api/status?network_id=${NET_B}`);
144
+ expect(cross.status).toBe(crossNoFilter.status);
145
+ if (cross.status !== 200) expect(cross.text).toBe(crossNoFilter.text);
146
+ else expect(JSON.parse(cross.text).sessions).toEqual([]);
147
+ });
148
+
149
+ test("unauthenticated read is refused exactly as without the filter", async () => {
150
+ const withFilter = await raw(null, `/api/status?node_id=node_sn_one`);
151
+ const without = await raw(null, `/api/status`);
152
+ expect(withFilter.status).toBe(without.status);
153
+ expect(withFilter.text).toBe(without.text);
154
+ });
155
+
156
+ test("/health advertises status_node_id", async () => {
157
+ const r = await raw(null, `/health`);
158
+ expect(r.status).toBe(200);
159
+ expect(JSON.parse(r.text).capabilities).toContain("status_node_id");
160
+ });
161
+ });