@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 +1 -1
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +75 -1
- package/src/route-timing.ts +111 -6
- package/src/server.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
+
});
|
package/src/route-timing.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/route-timing.ts
CHANGED
|
@@ -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
|
-
|
|
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) ──
|