@sleep2agi/commhub-server 0.9.0-preview.76 → 0.9.0-preview.78
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/mcp-route-label-http.test.ts +120 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements.ts +142 -29
- package/src/route-timing.test.ts +106 -9
- package/src/route-timing.ts +115 -32
- package/src/server.ts +39 -3
- package/src/status-node-id-filter-http.test.ts +161 -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 { recordRouteTiming, routeStats } from "./route-timing.js";
|
|
64
|
+
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats } 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";
|
|
@@ -103,6 +103,9 @@ if (AUTH_TOKEN) {
|
|
|
103
103
|
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// POST /mcp bodies up to this size are parsed once up front to label the route (see the /mcp handler).
|
|
107
|
+
const MCP_LABEL_MAX_BYTES = 1024 * 1024;
|
|
108
|
+
|
|
106
109
|
// Read version from package.json so banners and /health stay in sync.
|
|
107
110
|
const SERVER_VERSION = (() => {
|
|
108
111
|
try {
|
|
@@ -111,6 +114,9 @@ const SERVER_VERSION = (() => {
|
|
|
111
114
|
} catch { return "?"; }
|
|
112
115
|
})();
|
|
113
116
|
|
|
117
|
+
// Feature flags advertised on /health (see the /health handler). Exported for tests.
|
|
118
|
+
export const HUB_HEALTH_CAPABILITIES = ["status_node_id"] as const;
|
|
119
|
+
|
|
114
120
|
// In-memory log ring buffer — last N lines streamed via /api/server-logs.
|
|
115
121
|
// Wraps console.log/info/warn/error so EVERY existing log call lands here
|
|
116
122
|
// without source changes. Dashboard tails this buffer for "hub server log
|
|
@@ -941,9 +947,22 @@ return Bun.serve({
|
|
|
941
947
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
942
948
|
sessionIdGenerator: undefined,
|
|
943
949
|
});
|
|
950
|
+
// 2026-10-01 —— /api/stats/routes 里 POST /mcp 是最大的一项(生产 15 req/s),但看不出是哪个工具。
|
|
951
|
+
// 请求体我们自己读一次、解析一次,只取 method / params.name 做路由标签(mcpRouteLabel),
|
|
952
|
+
// 解析结果作为 parsedBody 交给 transport,不重复解析。没有 Content-Length 或超过 MCP_LABEL_MAX_BYTES
|
|
953
|
+
// 的请求体不碰,由 transport 照旧自己读。不是合法 JSON 时不传 parsedBody:transport 读已消费的
|
|
954
|
+
// body 失败,落进它自己的 catch,回的仍是原来那条 -32700「Parse error: Invalid JSON」(测试钉住)。
|
|
955
|
+
let mcpParsedBody: unknown;
|
|
956
|
+
if (req.method === "POST") {
|
|
957
|
+
const declared = Number(req.headers.get("content-length"));
|
|
958
|
+
if (req.headers.has("content-length") && Number.isFinite(declared) && declared <= MCP_LABEL_MAX_BYTES) {
|
|
959
|
+
try { mcpParsedBody = JSON.parse(await req.text()); } catch { mcpParsedBody = undefined; }
|
|
960
|
+
}
|
|
961
|
+
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
962
|
+
}
|
|
944
963
|
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
|
|
945
964
|
await mcpServer.connect(transport);
|
|
946
|
-
const response = await transport.handleRequest(req);
|
|
965
|
+
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
947
966
|
// Disconnect after response to prevent McpServer leak
|
|
948
967
|
setImmediate(() => mcpServer.close().catch(() => {}));
|
|
949
968
|
// #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
|
|
@@ -2007,6 +2026,11 @@ return Bun.serve({
|
|
|
2007
2026
|
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
2008
2027
|
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
2009
2028
|
},
|
|
2029
|
+
// Public, stable feature flags for REST reads whose own response can't carry them.
|
|
2030
|
+
// status_node_id: GET /api/status accepts ?node_id=<id> (one node's rows; with light=1 the
|
|
2031
|
+
// row also carries node_id). The /api/status body stays byte-identical without the param, so
|
|
2032
|
+
// the flag lives here — agent-nodes already read /health once at boot.
|
|
2033
|
+
capabilities: HUB_HEALTH_CAPABILITIES,
|
|
2010
2034
|
};
|
|
2011
2035
|
if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
|
|
2012
2036
|
return withCors(req, Response.json(body));
|
|
@@ -2180,9 +2204,10 @@ return Bun.serve({
|
|
|
2180
2204
|
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
2181
2205
|
// unaffected.
|
|
2182
2206
|
const isLight = url.searchParams.get("light") === "1";
|
|
2207
|
+
const nodeIdFilter = url.searchParams.get("node_id")?.trim();
|
|
2183
2208
|
const params: any[] = [];
|
|
2184
2209
|
let sql = isLight
|
|
2185
|
-
?
|
|
2210
|
+
? `SELECT alias, status, agent, task, server, updated_at, network_id${nodeIdFilter ? ", node_id" : ""} FROM sessions WHERE 1=1`
|
|
2186
2211
|
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
2187
2212
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
2188
2213
|
// `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
|
|
@@ -2195,6 +2220,15 @@ return Bun.serve({
|
|
|
2195
2220
|
params.push(aliasFilter);
|
|
2196
2221
|
sql += ` AND alias = ?${params.length}`;
|
|
2197
2222
|
}
|
|
2223
|
+
// `?node_id=<id>` narrows to one node's rows the same way (same scope, same projection). Every
|
|
2224
|
+
// agent-node resolves its own current alias every 30 s; unfiltered that is the whole network's
|
|
2225
|
+
// full projection (~91 KB gzip per call on production) to read one row. Advertised in /health
|
|
2226
|
+
// `capabilities` ("status_node_id") because old hubs ignore the parameter and — in the light
|
|
2227
|
+
// projection, which has no node_id — the caller could not tell its row from anyone else's.
|
|
2228
|
+
if (nodeIdFilter) {
|
|
2229
|
+
params.push(nodeIdFilter);
|
|
2230
|
+
sql += ` AND node_id = ?${params.length}`;
|
|
2231
|
+
}
|
|
2198
2232
|
sql += " ORDER BY updated_at DESC";
|
|
2199
2233
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
2200
2234
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
@@ -2211,6 +2245,8 @@ return Bun.serve({
|
|
|
2211
2245
|
updated_at: s.updated_at ?? null,
|
|
2212
2246
|
runtime: normalizeRuntime(s.agent),
|
|
2213
2247
|
network_id: s.network_id ?? null,
|
|
2248
|
+
// Only when filtering by node_id, so the unfiltered light response keeps its exact bytes.
|
|
2249
|
+
...(nodeIdFilter ? { node_id: s.node_id ?? null } : {}),
|
|
2214
2250
|
};
|
|
2215
2251
|
}
|
|
2216
2252
|
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
|
+
});
|