@sleep2agi/commhub-server 0.9.0-preview.101 → 0.9.0-preview.102
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/agent-groups-perf.test.ts +48 -15
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/server.ts +22 -18
- package/src/task-queue-ahead-scale.test.ts +44 -19
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/tools.ts +22 -1
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.102",
|
|
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",
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
|
|
5
5
|
// baseline —— bob 直接授权这 300 个节点(没有组);
|
|
6
6
|
// groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
|
|
7
|
-
// 两人看到的 Agent
|
|
7
|
+
// 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
|
|
8
8
|
// 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
|
|
9
|
+
// #517:差值怎么量见 lowDelta*() —— 交替且每轮翻转先后、样本 40 对、比两边的 p20(不是每轮差值的中位数)、
|
|
10
|
+
// 一轮超标再重量最多 2 轮。界限本身(max(5 ms, 15%))没动。
|
|
9
11
|
|
|
10
12
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
13
|
import { mkdtempSync, rmSync } from "fs";
|
|
@@ -30,10 +32,11 @@ const PER_GROUP = NODES / GROUPS;
|
|
|
30
32
|
const BOUND_MS = 5;
|
|
31
33
|
const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
|
|
32
34
|
// CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
|
|
33
|
-
//
|
|
35
|
+
// 性能用例另给显式超时(差值守卫最坏 3 轮 × 40 对,见 lowDelta)。
|
|
34
36
|
const WARMUP = 3;
|
|
35
|
-
const ITER = 15;
|
|
36
37
|
const PERF_TIMEOUT_MS = 60_000;
|
|
38
|
+
// #517 差值守卫:每轮 DELTA_PAIRS 对、比两边的 LOW_P 分位、超标再量最多 RETRIES 轮。
|
|
39
|
+
const DELTA_PAIRS = 40, LOW_P = 0.2, RETRIES = 2;
|
|
37
40
|
|
|
38
41
|
const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
|
|
39
42
|
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
@@ -56,15 +59,46 @@ function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: n
|
|
|
56
59
|
}
|
|
57
60
|
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
58
61
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const pct = (xs: number[], p: number) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
|
|
63
|
+
type LowDelta = { a: number; b: number; delta: number; aP50: number; bP50: number; ok: boolean };
|
|
64
|
+
/**
|
|
65
|
+
* #517 —— 「组授权比直接授权多出多少」的稳健量法。旧做法(15 对、每轮固定先 A 后 B、取每轮差值中位数)在 test638
|
|
66
|
+
* 两份 aggregate 并发时红过(本地 2 核 + 忙循环邻居实测 paired-delta 7.34 ms > 5 ms,同一轮两边中位数差出 10 ms)——
|
|
67
|
+
* 量的是负载,不是组展开。现在:
|
|
68
|
+
* - 每轮翻转先后(偶数轮 A→B,奇数轮 B→A),「第二个总吃前一个的 GC / 调度」这类顺序偏差两边对半分;
|
|
69
|
+
* - 每边 DELTA_PAIRS 个样本,比两边各自的 LOW_P 分位:调度噪声只会加时间,低尾最接近真实成本;
|
|
70
|
+
* - 一轮超标就整轮重量,最多 RETRIES 次;真回归(组路径每节点多查一次,#2133 那种 N+1)每一轮都超。
|
|
71
|
+
*/
|
|
72
|
+
function lowDelta(a: () => void, b: () => void, bound: (base: number) => number): LowDelta {
|
|
73
|
+
let r: LowDelta | null = null;
|
|
74
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
75
|
+
for (let i = 0; i < WARMUP; i++) { a(); b(); }
|
|
76
|
+
const as: number[] = [], bs: number[] = [];
|
|
77
|
+
for (let i = 0; i < DELTA_PAIRS; i++) {
|
|
78
|
+
const time = (f: () => void) => { const t = performance.now(); f(); return performance.now() - t; };
|
|
79
|
+
if (i % 2 === 0) { as.push(time(a)); bs.push(time(b)); } else { bs.push(time(b)); as.push(time(a)); }
|
|
80
|
+
}
|
|
81
|
+
const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
|
|
82
|
+
r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
|
|
83
|
+
if (r.ok) return r;
|
|
66
84
|
}
|
|
67
|
-
return
|
|
85
|
+
return r!;
|
|
86
|
+
}
|
|
87
|
+
async function lowDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, bound: (base: number) => number, label: string): Promise<LowDelta> {
|
|
88
|
+
let r: LowDelta | null = null;
|
|
89
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
90
|
+
for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
|
|
91
|
+
const as: number[] = [], bs: number[] = [];
|
|
92
|
+
const time = async (f: () => Promise<void>) => { const t = performance.now(); await f(); return performance.now() - t; };
|
|
93
|
+
for (let i = 0; i < DELTA_PAIRS; i++) {
|
|
94
|
+
if (i % 2 === 0) { as.push(await time(a)); bs.push(await time(b)); } else { bs.push(await time(b)); as.push(await time(a)); }
|
|
95
|
+
}
|
|
96
|
+
const lowA = pct(as, LOW_P), lowB = pct(bs, LOW_P);
|
|
97
|
+
r = { a: lowA, b: lowB, delta: lowB - lowA, aP50: pct(as, 0.5), bP50: pct(bs, 0.5), ok: lowB - lowA <= bound(lowA) };
|
|
98
|
+
console.log(`[perf] ${label} pass ${attempt + 1}: p${LOW_P * 100} direct-300=${lowA.toFixed(2)}ms groups-50x6=${lowB.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(lowA).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)}) ${r.ok ? "ok" : "OVER"}`);
|
|
99
|
+
if (r.ok) return r;
|
|
100
|
+
}
|
|
101
|
+
return r!;
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
beforeAll(async () => {
|
|
@@ -123,8 +157,8 @@ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
|
123
157
|
});
|
|
124
158
|
|
|
125
159
|
test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
|
|
126
|
-
const r =
|
|
127
|
-
console.log(`[perf] visibleAgents
|
|
160
|
+
const r = lowDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, bound);
|
|
161
|
+
console.log(`[perf] visibleAgents p${LOW_P * 100}: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms (p50 ${r.aP50.toFixed(2)} / ${r.bP50.toFixed(2)})`);
|
|
128
162
|
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
129
163
|
}, PERF_TIMEOUT_MS);
|
|
130
164
|
|
|
@@ -157,8 +191,7 @@ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
|
157
191
|
const body = await r.json() as any;
|
|
158
192
|
if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
|
|
159
193
|
};
|
|
160
|
-
const r = await
|
|
161
|
-
console.log(`[perf] GET /api/status median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
194
|
+
const r = await lowDeltaAsync(() => hit(bob.token), () => hit(alice.token), bound, "GET /api/status");
|
|
162
195
|
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
163
196
|
}, PERF_TIMEOUT_MS);
|
|
164
197
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// #550 — the sticky session capability flags (rules_file_capable / skills_capable /
|
|
2
|
+
// files_capable / logs_capable) survive a session handover.
|
|
3
|
+
//
|
|
4
|
+
// report_status for an alias that already has a row under ANOTHER resume_id takes
|
|
5
|
+
// the DELETE + INSERT handover path. That path carried registered_at and the
|
|
6
|
+
// descriptive columns, but not the capability flags, so the new row started at
|
|
7
|
+
// the column default 0 — although the flags are meant to be sticky (only ever set
|
|
8
|
+
// to 1). Board #548: a production node's MCP channel reconnected under a new
|
|
9
|
+
// resume_id and the rules-file / skills / files / logs doorbells went dark.
|
|
10
|
+
//
|
|
11
|
+
// Pinned through the real HTTP entrance (POST /mcp with the node's own token):
|
|
12
|
+
// 1. flags set on resume A are still 1 after the same alias reports resume B
|
|
13
|
+
// without them — and after it switches back to A.
|
|
14
|
+
// 2. only flags that were 1 are carried; a 0 is never promoted.
|
|
15
|
+
// 3. a later report omitting the flags on the same resume_id still keeps them.
|
|
16
|
+
// 4. a user token cannot report for the alias, so it cannot hand the flags to a
|
|
17
|
+
// session of its own.
|
|
18
|
+
//
|
|
19
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/report-status-handover-capabilities-http.test.ts
|
|
20
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
26
|
+
import { db } from "./db.js";
|
|
27
|
+
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-handover-caps-"));
|
|
29
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
30
|
+
if (!activeDbPath) throw new Error("report-status-handover-capabilities requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
31
|
+
|
|
32
|
+
const stamp = Date.now();
|
|
33
|
+
let server: any;
|
|
34
|
+
let base = "";
|
|
35
|
+
let ownerToken = "", ownerId = "", NET = "";
|
|
36
|
+
type Node = { alias: string; nodeId: string; token: string };
|
|
37
|
+
const nodes: Record<string, Node> = {};
|
|
38
|
+
|
|
39
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
40
|
+
const res = await fetch(`${base}/mcp`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
43
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
44
|
+
});
|
|
45
|
+
const raw = await res.text();
|
|
46
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
47
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
48
|
+
const text = payload.result.content[0].text;
|
|
49
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// resume_id is the sessions primary key — every alias below uses its own (cc-*-<nodeId>).
|
|
54
|
+
const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true, logs_capable: true };
|
|
55
|
+
const report = (token: string, n: Node, resume: string, extra: Record<string, unknown> = {}) =>
|
|
56
|
+
tool(token, "report_status", { resume_id: resume, alias: n.alias, status: "idle", agent: "claude-code", network_id: NET, ...extra });
|
|
57
|
+
|
|
58
|
+
type CapRow = { resume_id: string; rules_file_capable: number; skills_capable: number; files_capable: number; logs_capable: number };
|
|
59
|
+
const capRows = (alias: string) =>
|
|
60
|
+
db.all<CapRow>(
|
|
61
|
+
"SELECT resume_id, rules_file_capable, skills_capable, files_capable, logs_capable FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
62
|
+
alias, NET,
|
|
63
|
+
).map((r) => ({
|
|
64
|
+
resume_id: r.resume_id,
|
|
65
|
+
rules_file_capable: Number(r.rules_file_capable), skills_capable: Number(r.skills_capable),
|
|
66
|
+
files_capable: Number(r.files_capable), logs_capable: Number(r.logs_capable),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
71
|
+
process.env.HOST = "127.0.0.1";
|
|
72
|
+
const owner = register(`hcap_owner_${stamp}`, "HandoverCaps123!", undefined, "seed");
|
|
73
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
|
|
74
|
+
for (const key of ["all", "some", "user"]) {
|
|
75
|
+
const alias = `hcap-${key}-${stamp}`;
|
|
76
|
+
const nodeId = `n_hcap_${key}_${stamp}`;
|
|
77
|
+
const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
|
|
78
|
+
expect(minted.ok).toBe(true);
|
|
79
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
80
|
+
}
|
|
81
|
+
const mod: any = await import("./server.js");
|
|
82
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
83
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
84
|
+
}, 30_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { server?.stop?.(true); } catch {}
|
|
88
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#550 sticky capability flags survive a resume_id handover", () => {
|
|
92
|
+
test("all four flags carry to the new resume_id, and back again", async () => {
|
|
93
|
+
const n = nodes.all!;
|
|
94
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
|
|
95
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
96
|
+
|
|
97
|
+
// The channel reconnects under a new resume_id and reports WITHOUT the flags.
|
|
98
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
99
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
100
|
+
|
|
101
|
+
// Same resume_id, flags omitted again (status-only report) — still sticky.
|
|
102
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
103
|
+
expect(capRows(n.alias)[0]).toMatchObject({ rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 });
|
|
104
|
+
|
|
105
|
+
// Two reporters taking turns (agent-node + TUI MCP): switching back keeps them too.
|
|
106
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`)).ok).toBe(true);
|
|
107
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
108
|
+
}, 20_000);
|
|
109
|
+
|
|
110
|
+
test("only flags that were 1 are carried — a 0 is never promoted", async () => {
|
|
111
|
+
const n = nodes.some!;
|
|
112
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, { rules_file_capable: true, logs_capable: true })).ok).toBe(true);
|
|
113
|
+
expect((await report(n.token, n, `cc-b-${n.nodeId}`)).ok).toBe(true);
|
|
114
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-b-${n.nodeId}`, rules_file_capable: 1, skills_capable: 0, files_capable: 0, logs_capable: 1 }]);
|
|
115
|
+
}, 20_000);
|
|
116
|
+
|
|
117
|
+
test("a user token cannot report for the alias, so it cannot take over the flags", async () => {
|
|
118
|
+
const n = nodes.user!;
|
|
119
|
+
expect((await report(n.token, n, `cc-a-${n.nodeId}`, ALL_FLAGS)).ok).toBe(true);
|
|
120
|
+
const r = await report(ownerToken, n, `cc-hijack-${n.nodeId}`);
|
|
121
|
+
expect(r.ok).toBe(false);
|
|
122
|
+
expect(capRows(n.alias)).toEqual([{ resume_id: `cc-a-${n.nodeId}`, rules_file_capable: 1, skills_capable: 1, files_capable: 1, logs_capable: 1 }]);
|
|
123
|
+
}, 20_000);
|
|
124
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -83,6 +83,7 @@ import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry
|
|
|
83
83
|
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
84
84
|
import { createProductionSideThreadTransport } from "./side-thread-production.js";
|
|
85
85
|
import { parseHubTimestamp } from "./hub-timestamp";
|
|
86
|
+
import { logTaskRejection } from "./task-rejection-log.js";
|
|
86
87
|
|
|
87
88
|
const PORT = resolvePort(process.env.PORT);
|
|
88
89
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -3411,15 +3412,18 @@ return Bun.serve({
|
|
|
3411
3412
|
|
|
3412
3413
|
// ── REST: send task ──
|
|
3413
3414
|
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
3415
|
+
// #552 — every non-2xx below goes through reject() so it leaves one log line
|
|
3416
|
+
// (status, error, user, requested alias/network/client_request_id; never token/text).
|
|
3414
3417
|
let raw: unknown;
|
|
3418
|
+
const reject = (res: Response) => logTaskRejection(res, restAuth?.username, raw);
|
|
3415
3419
|
try {
|
|
3416
3420
|
raw = await req.json();
|
|
3417
3421
|
} catch {
|
|
3418
|
-
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
3422
|
+
return reject(withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 })));
|
|
3419
3423
|
}
|
|
3420
3424
|
const parsed = TaskSchema.safeParse(raw);
|
|
3421
3425
|
if (!parsed.success) {
|
|
3422
|
-
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
3426
|
+
return reject(withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 })));
|
|
3423
3427
|
}
|
|
3424
3428
|
const body = parsed.data;
|
|
3425
3429
|
let taskNetId: string | null = null;
|
|
@@ -3427,7 +3431,7 @@ return Bun.serve({
|
|
|
3427
3431
|
taskNetId = restAuth.networkId;
|
|
3428
3432
|
} else if (body.network_id) {
|
|
3429
3433
|
if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
|
|
3430
|
-
return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
|
|
3434
|
+
return reject(withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 })));
|
|
3431
3435
|
}
|
|
3432
3436
|
taskNetId = body.network_id;
|
|
3433
3437
|
} else {
|
|
@@ -3436,34 +3440,34 @@ return Bun.serve({
|
|
|
3436
3440
|
: null;
|
|
3437
3441
|
}
|
|
3438
3442
|
if (restAuth && !taskNetId) {
|
|
3439
|
-
return withCors(req, Response.json({
|
|
3443
|
+
return reject(withCors(req, Response.json({
|
|
3440
3444
|
ok: false,
|
|
3441
3445
|
error: "network_id_required",
|
|
3442
3446
|
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
3443
|
-
}, { status: 400 }));
|
|
3447
|
+
}, { status: 400 })));
|
|
3444
3448
|
}
|
|
3445
3449
|
// 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
|
|
3446
3450
|
// 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
|
|
3447
3451
|
const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
|
|
3448
3452
|
if (restrictedSender) {
|
|
3449
3453
|
if (!canRestWriteNetworkAsHuman(restAuth, taskNetId, isAdmin)) {
|
|
3450
|
-
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3454
|
+
return reject(withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 })));
|
|
3451
3455
|
}
|
|
3452
3456
|
const restrictedTarget = resolveCanonicalAlias(taskNetId, body.alias).alias;
|
|
3453
3457
|
const restrictedSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", restrictedTarget, taskNetId);
|
|
3454
3458
|
if (!restrictedSession || !canMessageAgent(restAuth!.userId, taskNetId, { alias: restrictedTarget, nodeId: restrictedSession.node_id })) {
|
|
3455
|
-
return withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 }));
|
|
3459
|
+
return reject(withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 })));
|
|
3456
3460
|
}
|
|
3457
3461
|
if (typeof body.from === "string" && body.from.trim() && body.from.trim() !== restAuth!.username) {
|
|
3458
|
-
return withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 }));
|
|
3462
|
+
return reject(withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 })));
|
|
3459
3463
|
}
|
|
3460
3464
|
for (const raw of [(body as any).attachments, (body as any).meta?.attachments]) {
|
|
3461
3465
|
if (restrictedMemberAttachmentsDenied(restAuth!.userId, restAuth!.username, taskNetId!, raw)) {
|
|
3462
|
-
return withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 }));
|
|
3466
|
+
return reject(withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 })));
|
|
3463
3467
|
}
|
|
3464
3468
|
}
|
|
3465
3469
|
} else if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
3466
|
-
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3470
|
+
return reject(withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 })));
|
|
3467
3471
|
}
|
|
3468
3472
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
3469
3473
|
const targetAlias = canonical.alias;
|
|
@@ -3471,17 +3475,17 @@ return Bun.serve({
|
|
|
3471
3475
|
alias: targetAlias,
|
|
3472
3476
|
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
|
|
3473
3477
|
}));
|
|
3474
|
-
if (nodeDenied) return nodeDenied;
|
|
3478
|
+
if (nodeDenied) return reject(nodeDenied);
|
|
3475
3479
|
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
3476
3480
|
if (target.state === "not_found") {
|
|
3477
|
-
return withCors(req, Response.json({
|
|
3481
|
+
return reject(withCors(req, Response.json({
|
|
3478
3482
|
ok: false,
|
|
3479
3483
|
error: "alias_not_found",
|
|
3480
3484
|
message: target.message,
|
|
3481
3485
|
alias: targetAlias,
|
|
3482
3486
|
queued: false,
|
|
3483
3487
|
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
3484
|
-
}, { status: 404 }));
|
|
3488
|
+
}, { status: 404 })));
|
|
3485
3489
|
}
|
|
3486
3490
|
// Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
|
|
3487
3491
|
// fromIdentityMismatchReply). Both transports resolve the same
|
|
@@ -3507,13 +3511,13 @@ return Bun.serve({
|
|
|
3507
3511
|
requestedFrom: body.from,
|
|
3508
3512
|
});
|
|
3509
3513
|
if (!identity.ok) {
|
|
3510
|
-
return withCors(req, Response.json({
|
|
3514
|
+
return reject(withCors(req, Response.json({
|
|
3511
3515
|
ok: false,
|
|
3512
3516
|
error: identity.error,
|
|
3513
3517
|
message: identity.message,
|
|
3514
3518
|
token_alias: identity.tokenAlias,
|
|
3515
3519
|
requested_from_session: identity.requestedFromSession,
|
|
3516
|
-
}, { status: 403 }));
|
|
3520
|
+
}, { status: 403 })));
|
|
3517
3521
|
}
|
|
3518
3522
|
const fromSession = identity.fromSession;
|
|
3519
3523
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
@@ -3528,7 +3532,7 @@ return Bun.serve({
|
|
|
3528
3532
|
// size cap, etc.).
|
|
3529
3533
|
const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
|
|
3530
3534
|
if (!attachmentsResult.ok) {
|
|
3531
|
-
return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
|
|
3535
|
+
return reject(withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 })));
|
|
3532
3536
|
}
|
|
3533
3537
|
const mergedMeta = attachmentsResult.attachments.length
|
|
3534
3538
|
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
@@ -3616,14 +3620,14 @@ return Bun.serve({
|
|
|
3616
3620
|
{
|
|
3617
3621
|
const lc = assertNodeActive(targetAlias, taskNetId ?? null);
|
|
3618
3622
|
if (!lc.ok) {
|
|
3619
|
-
return withCors(req, Response.json(lc, { status: 409 }));
|
|
3623
|
+
return reject(withCors(req, Response.json(lc, { status: 409 })));
|
|
3620
3624
|
}
|
|
3621
3625
|
}
|
|
3622
3626
|
// #460 — refuse dispatch to a node whose fresh health says a layer is down (409, same as the
|
|
3623
3627
|
// lifecycle guard above). Unknown / stale health = allowed: old nodes keep working.
|
|
3624
3628
|
{
|
|
3625
3629
|
const hg = assertNodeHealthy(targetAlias, taskNetId ?? null, { force: body.force === true, forceAllowed: !restAuth?.networkId });
|
|
3626
|
-
if (!hg.ok) return withCors(req, Response.json(hg, { status: 409 }));
|
|
3630
|
+
if (!hg.ok) return reject(withCors(req, Response.json(hg, { status: 409 })));
|
|
3627
3631
|
}
|
|
3628
3632
|
// #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
|
|
3629
3633
|
const queueInfo = dispatchQueueInfo(targetAlias, taskNetId, target.session?.status, ttlSeconds);
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// the whole-table one is driven by the covering idx_tasks_created_queue (no task-row lookups), the alias list
|
|
10
10
|
// and the per-send count by idx_tasks_to_created;
|
|
11
11
|
// 2. the cost, relative only (no absolute ms, so a slow runner can't fail it): the queue_depth statement is at most
|
|
12
|
-
// half the #2325 statement measured on the same DB in the same loop
|
|
13
|
-
//
|
|
14
|
-
// read
|
|
12
|
+
// half the #2325 statement measured on the same DB in the same loop; and, on the low percentile of alternated
|
|
13
|
+
// samples (#517), the statement costs at most half a full /api/status read (cache bypassed) without it, and that
|
|
14
|
+
// read with queue_depth is at most 50 % slower than with it switched off (= the pre-#2325 read);
|
|
15
15
|
// 3. the counts are still right at that scale (stale `acked` rows are outside the 24 h window).
|
|
16
16
|
//
|
|
17
17
|
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/task-queue-ahead-scale.test.ts
|
|
@@ -35,6 +35,8 @@ const RECENT = 1_900; // created in the last 24 h
|
|
|
35
35
|
const OPEN_EVERY = 30; // 3 in every OPEN_EVERY recent tasks are still open (~190 of 1.9k; see the cost test)
|
|
36
36
|
const HOT = "scale-node-7"; // most stuck rows land on this one (the per-send plan must not care)
|
|
37
37
|
const N = 25;
|
|
38
|
+
/** #517: A/B pairs per pass, the percentile compared, and how many extra passes a failing pass gets. */
|
|
39
|
+
const AB_PAIRS = 60, LOW_P = 0.2, RETRIES = 2;
|
|
38
40
|
/** The #2325 statement (bare columns): the in-test baseline the fixed query is measured against. */
|
|
39
41
|
const SQL_2325 = `SELECT network_id, to_name, COUNT(*) AS n FROM tasks
|
|
40
42
|
WHERE status IN ('created', 'delivered', 'acked', 'running') AND created_at >= datetime('now', '-86400 seconds')
|
|
@@ -170,34 +172,56 @@ describe.skipIf(PG)("#500 queue_depth at production scale (57k tasks, 31k stuck
|
|
|
170
172
|
s0 = performance.now(); db.all(SQL_2325); const b = performance.now() - s0;
|
|
171
173
|
if (i >= 3) { fixed.push(a); old.push(b); }
|
|
172
174
|
}
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// Measured locally (this fixture, covering index): ≈ 0.15× (1.0 vs 6.5 ms); on the production copy 0.3 vs 55 ms.
|
|
176
|
+
const qP50 = pct(fixed, 0.5), oldP50 = pct(old, 0.5);
|
|
177
|
+
console.log(`[perf] queue_depth p50=${qP50.toFixed(2)}ms vs #2325 statement p50=${oldP50.toFixed(2)}ms`);
|
|
178
|
+
expect(qP50).toBeLessThanOrEqual(0.5 * oldP50);
|
|
179
|
+
|
|
180
|
+
// Second budget: queue_depth adds at most +50 % to a full /api/status read (cache bypassed) — the same read
|
|
181
|
+
// with it switched off is the pre-#2325 read. #517: the first version compared the p50 of `with` and `without`
|
|
182
|
+
// on a ~4 ms read and asserted the *difference*; under test638's concurrent aggregates a difference of two
|
|
183
|
+
// noisy p50s is mostly noise (CI #2355: statement 0.64 ms, yet with−without = 2.4 ms > 2.03 ms budget). Now:
|
|
184
|
+
// - each pass alternates A/B request by request (order flipped every pair) and also times the bare
|
|
185
|
+
// queue_depth statement between the reads, so all three series share one time window;
|
|
186
|
+
// - it compares a low percentile (LOW_P) rather than the p50: scheduler noise only ever adds time, so the
|
|
187
|
+
// lower tail is the closest a busy runner gets to the real cost;
|
|
188
|
+
// - it asserts both what the comment claims — the statement's own cost ≤ 50 % of the read without it — and
|
|
189
|
+
// the end-to-end with/without delta on the same low percentile;
|
|
190
|
+
// - a failing pass is re-measured up to RETRIES more times; the real regression (the #2325 statement through
|
|
191
|
+
// idx_tasks_status, 5–8× the fixed cost) fails every pass, noise does not.
|
|
177
192
|
const cache = await import("./status-read-cache.js");
|
|
178
193
|
cache.__setStatusCacheBypassForTest(true);
|
|
179
|
-
const on: number[] = [], off: number[] = [];
|
|
180
194
|
const readOnce = async (depthOn: boolean) => {
|
|
181
195
|
mod.__setQueueDepthOffForTest(!depthOn);
|
|
182
196
|
const s0 = performance.now();
|
|
183
197
|
await (await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { Authorization: `Bearer ${token}` } })).text();
|
|
184
198
|
return performance.now() - s0;
|
|
185
199
|
};
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
const abPass = async () => {
|
|
201
|
+
const on: number[] = [], off: number[] = [], q: number[] = [];
|
|
202
|
+
for (let i = 0; i < AB_PAIRS + 3; i++) {
|
|
188
203
|
const first = i % 2 === 0;
|
|
189
204
|
const a = await readOnce(first), b = await readOnce(!first);
|
|
190
|
-
|
|
205
|
+
mod.__setQueueDepthOffForTest(false);
|
|
206
|
+
const s0 = performance.now(); mod.queueDepthByNode(); const c = performance.now() - s0;
|
|
207
|
+
if (i >= 3) { (first ? on : off).push(a); (first ? off : on).push(b); q.push(c); }
|
|
208
|
+
}
|
|
209
|
+
const onLow = pct(on, LOW_P), offLow = pct(off, LOW_P), qLow = pct(q, LOW_P);
|
|
210
|
+
return { onLow, offLow, qLow, onP50: pct(on, 0.5), offP50: pct(off, 0.5),
|
|
211
|
+
okDirect: qLow <= 0.5 * offLow, okRead: onLow - offLow <= 0.5 * offLow };
|
|
212
|
+
};
|
|
213
|
+
let r: Awaited<ReturnType<typeof abPass>> | null = null;
|
|
214
|
+
try {
|
|
215
|
+
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
216
|
+
r = await abPass();
|
|
217
|
+
console.log(`[perf] pass ${attempt + 1}: p${LOW_P * 100} queue_depth=${r.qLow.toFixed(2)}ms; full /api/status (bypass) p${LOW_P * 100} with=${r.onLow.toFixed(2)}ms without=${r.offLow.toFixed(2)}ms (+${(100 * (r.onLow - r.offLow) / r.offLow).toFixed(0)}%); p50 with=${r.onP50.toFixed(2)}ms without=${r.offP50.toFixed(2)}ms; direct ${r.okDirect ? "ok" : "OVER"} read ${r.okRead ? "ok" : "OVER"}`);
|
|
218
|
+
if (r.okDirect && r.okRead) break;
|
|
191
219
|
}
|
|
192
220
|
} finally {
|
|
193
221
|
mod.__setQueueDepthOffForTest(false);
|
|
194
222
|
cache.__setStatusCacheBypassForTest(false);
|
|
195
223
|
}
|
|
196
|
-
|
|
197
|
-
console.log(`[perf] queue_depth p50=${qP50.toFixed(2)}ms vs #2325 statement p50=${oldP50.toFixed(2)}ms; full /api/status (bypass) p50 with=${onP50.toFixed(2)}ms without=${offP50.toFixed(2)}ms (+${(100 * (onP50 - offP50) / offP50).toFixed(0)}%) p90 with=${pct(on, 0.9).toFixed(2)}ms`);
|
|
198
|
-
// Measured locally (this fixture, covering index): ≈ 0.15× (1.0 vs 6.5 ms); on the production copy 0.3 vs 55 ms.
|
|
199
|
-
expect(qP50).toBeLessThanOrEqual(0.5 * oldP50);
|
|
200
|
-
// Why the budget is "vs the same read without queue_depth", and what it was measured at (2026-10-04):
|
|
224
|
+
// What the read budget was measured at (2026-10-04, p50s, before #517):
|
|
201
225
|
// production copy (VACUUM INTO of the live DB: 306 sessions, 57k tasks, 64 open in the last 24 h on 4 nodes),
|
|
202
226
|
// full /api/status p50, cache bypassed, 3 runs each —
|
|
203
227
|
// pre-#2325 10.5–11.3 ms · #2325 68–71 ms · first fix (idx_tasks_created + row lookups) 16.1–17.1 ms (+50 %,
|
|
@@ -205,7 +229,8 @@ describe.skipIf(PG)("#500 queue_depth at production scale (57k tasks, 31k stuck
|
|
|
205
229
|
// this fixture: +17–31 %. It is harsher than production: ~190 open tasks in the window spread over ~150 nodes
|
|
206
230
|
// (3× production's open count, ~38× its groups) and one-word session rows that make the rest of the read cheap.
|
|
207
231
|
// With the original 1-in-7 open share (813 open) it measured +55–62 % even with the covering index — the
|
|
208
|
-
// GROUP BY over 300 groups, not the index.
|
|
209
|
-
expect(
|
|
210
|
-
|
|
232
|
+
// GROUP BY over 300 groups, not the index.
|
|
233
|
+
expect(r!.qLow).toBeLessThanOrEqual(0.5 * r!.offLow);
|
|
234
|
+
expect(r!.onLow - r!.offLow).toBeLessThanOrEqual(0.5 * r!.offLow);
|
|
235
|
+
}, 120_000);
|
|
211
236
|
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// #552 — every rejected REST POST /api/task leaves exactly one hub log line.
|
|
2
|
+
//
|
|
3
|
+
// Incident: a restricted member's app sends were refused and the hub log
|
|
4
|
+
// showed nothing, so "never arrived" and "rejected" were indistinguishable.
|
|
5
|
+
// Real Bun.serve on a private port + temp DB; console.log is captured around
|
|
6
|
+
// each request. The line must carry status + error + user + requested
|
|
7
|
+
// alias/network/client_request_id, and must NOT carry the bearer token or the
|
|
8
|
+
// task text.
|
|
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 } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { formatTaskRejection } from "./task-rejection-log.js";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-task-reject-log-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
const PW = "RejectLogPassw0rd!x";
|
|
22
|
+
let adminToken = "";
|
|
23
|
+
let adminName = "";
|
|
24
|
+
let NET = "";
|
|
25
|
+
let aliceToken = "";
|
|
26
|
+
const AGENT = { alias: "rejlog-agent", node: "node_rejlog" };
|
|
27
|
+
const SECRET_TEXT = "TOP-SECRET-TASK-BODY-552";
|
|
28
|
+
|
|
29
|
+
async function postTask(token: string | null, body: string): Promise<{ status: number; json: any; lines: string[] }> {
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
const orig = console.log;
|
|
32
|
+
console.log = (...args: any[]) => { lines.push(args.map(String).join(" ")); };
|
|
33
|
+
try {
|
|
34
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
35
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
36
|
+
const res = await fetch(`${BASE}/api/task`, { method: "POST", headers, body });
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
let json: any = text;
|
|
39
|
+
try { json = JSON.parse(text); } catch {}
|
|
40
|
+
return { status: res.status, json, lines };
|
|
41
|
+
} finally {
|
|
42
|
+
console.log = orig;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const rejectedLines = (lines: string[]) => lines.filter(l => l.includes("→ /api/task →") && l.includes("REJECTED"));
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
49
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
50
|
+
process.env.HOST = "127.0.0.1";
|
|
51
|
+
|
|
52
|
+
const admin = register(`rejlog_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
53
|
+
expect(admin.ok).toBe(true);
|
|
54
|
+
adminToken = admin.token!;
|
|
55
|
+
adminName = admin.user!.username;
|
|
56
|
+
NET = admin.network_id!;
|
|
57
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.user!.user_id]);
|
|
58
|
+
db.run(
|
|
59
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
|
|
60
|
+
[AGENT.node, AGENT.alias, NET, admin.user!.user_id],
|
|
61
|
+
);
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
|
|
64
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[`resume_${AGENT.node}`, AGENT.alias, AGENT.node, NET],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const mod: any = await import("./server.js");
|
|
69
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
70
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
71
|
+
|
|
72
|
+
// A restricted member (default agent_access='granted', zero grants) who also
|
|
73
|
+
// owns a personal network → two memberships, like the #552 app user.
|
|
74
|
+
const created = await fetch(`${BASE}/api/admin/users`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ username: "rejlog_alice", password: PW, network_id: NET, role: "member" }),
|
|
78
|
+
});
|
|
79
|
+
expect(created.status).toBe(200);
|
|
80
|
+
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
81
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ username: "rejlog_alice", password: PW }),
|
|
83
|
+
});
|
|
84
|
+
expect(login.status).toBe(200);
|
|
85
|
+
aliceToken = (await login.json() as any).token;
|
|
86
|
+
}, 30_000);
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
try { hub?.stop?.(true); } catch {}
|
|
90
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function assertNoSecrets(line: string, token: string) {
|
|
94
|
+
expect(line).not.toContain(token);
|
|
95
|
+
expect(line).not.toContain(SECRET_TEXT);
|
|
96
|
+
expect(line).not.toContain("file_");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("#552 REST POST /api/task rejections are logged", () => {
|
|
100
|
+
test("alias_not_found (404): one line with status, error, user, alias, network, client_request_id", async () => {
|
|
101
|
+
const r = await postTask(adminToken, JSON.stringify({ alias: "no-such-agent", task: SECRET_TEXT, network_id: NET, meta: { client_request_id: "crid-552-a" } }));
|
|
102
|
+
expect(r.status).toBe(404);
|
|
103
|
+
expect(r.json.error).toBe("alias_not_found");
|
|
104
|
+
const lines = rejectedLines(r.lines);
|
|
105
|
+
expect(lines.length).toBe(1);
|
|
106
|
+
expect(lines[0]).toMatch(/^\[\d\d:\d\d:\d\d\] /);
|
|
107
|
+
expect(lines[0]).toContain(`${adminName} → /api/task → no-such-agent: REJECTED 404 alias_not_found`);
|
|
108
|
+
expect(lines[0]).toContain(`net=${NET}`);
|
|
109
|
+
expect(lines[0]).toContain("crid=crid-552-a");
|
|
110
|
+
assertNoSecrets(lines[0], adminToken);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("network_id_required (400) for a member of two networks who omits network_id", async () => {
|
|
114
|
+
const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT }));
|
|
115
|
+
expect(r.status).toBe(400);
|
|
116
|
+
expect(r.json.error).toBe("network_id_required");
|
|
117
|
+
const lines = rejectedLines(r.lines);
|
|
118
|
+
expect(lines.length).toBe(1);
|
|
119
|
+
expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 400 network_id_required (net=-)`);
|
|
120
|
+
assertNoSecrets(lines[0], aliceToken);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("agent_not_granted (403) — the restricted-member case from #552", async () => {
|
|
124
|
+
const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT, network_id: NET, attachments: [{ file_id: "file_deadbeef" }] }));
|
|
125
|
+
expect(r.status).toBe(403);
|
|
126
|
+
expect(r.json.error).toBe("agent_not_granted");
|
|
127
|
+
const lines = rejectedLines(r.lines);
|
|
128
|
+
expect(lines.length).toBe(1);
|
|
129
|
+
expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 403 agent_not_granted (net=${NET})`);
|
|
130
|
+
assertNoSecrets(lines[0], aliceToken);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("invalid JSON (400) is logged with the user and '-' placeholders", async () => {
|
|
134
|
+
const r = await postTask(aliceToken, "{not json");
|
|
135
|
+
expect(r.status).toBe(400);
|
|
136
|
+
const lines = rejectedLines(r.lines);
|
|
137
|
+
expect(lines.length).toBe(1);
|
|
138
|
+
expect(lines[0]).toContain("rejlog_alice → /api/task → -: REJECTED 400 invalid JSON (net=-)");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a successful send logs no REJECTED line", async () => {
|
|
142
|
+
const r = await postTask(adminToken, JSON.stringify({ alias: AGENT.alias, task: "hello", network_id: NET }));
|
|
143
|
+
expect(r.status).toBe(200);
|
|
144
|
+
expect(rejectedLines(r.lines).length).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("formatTaskRejection", () => {
|
|
149
|
+
test("request-supplied values cannot inject extra log lines and are clipped", () => {
|
|
150
|
+
const line = formatTaskRejection({
|
|
151
|
+
status: 404, error: "alias_not_found", username: null,
|
|
152
|
+
body: { alias: "evil\n[00:00:00] admin → forged", network_id: "n".repeat(200), meta: { client_request_id: "x\r\ny" } },
|
|
153
|
+
});
|
|
154
|
+
expect(line).not.toContain("\n");
|
|
155
|
+
expect(line).not.toContain("\r");
|
|
156
|
+
expect(line).toContain("anon → /api/task → evil [00:00:00] admin → forged: REJECTED 404 alias_not_found");
|
|
157
|
+
expect(line).toContain(`net=${"n".repeat(64)}…`);
|
|
158
|
+
expect(line).toContain("crid=x y");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// #552 — one log line for every non-2xx answer from REST POST /api/task.
|
|
2
|
+
//
|
|
3
|
+
// A restricted member's app sends were rejected and the hub log showed
|
|
4
|
+
// nothing, so "the request never arrived" and "the hub refused it" looked
|
|
5
|
+
// identical from the server side. Every rejection branch now goes through
|
|
6
|
+
// logTaskRejection().
|
|
7
|
+
//
|
|
8
|
+
// What is logged is deliberately narrow: status, error code, the
|
|
9
|
+
// authenticated username, and the *requested* alias / network_id /
|
|
10
|
+
// client_request_id. Never the bearer token, the task text, or attachment
|
|
11
|
+
// ids. Request-supplied values are clipped and stripped of control
|
|
12
|
+
// characters so a client cannot forge extra log lines.
|
|
13
|
+
|
|
14
|
+
function ts(): string {
|
|
15
|
+
return new Date().toTimeString().slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clip(value: unknown, max = 64): string {
|
|
19
|
+
if (typeof value !== "string" || !value) return "-";
|
|
20
|
+
const clean = value.replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim();
|
|
21
|
+
if (!clean) return "-";
|
|
22
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TaskRejectionContext {
|
|
26
|
+
status: number;
|
|
27
|
+
error: unknown;
|
|
28
|
+
username: string | null | undefined;
|
|
29
|
+
/** The raw request body (or null when it did not parse). Only alias, network_id and meta.client_request_id are read. */
|
|
30
|
+
body: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatTaskRejection(ctx: TaskRejectionContext): string {
|
|
34
|
+
const b = ctx.body && typeof ctx.body === "object" ? (ctx.body as Record<string, any>) : {};
|
|
35
|
+
const meta = b.meta && typeof b.meta === "object" ? b.meta : {};
|
|
36
|
+
const crid = typeof meta.client_request_id === "string" ? ` crid=${clip(meta.client_request_id)}` : "";
|
|
37
|
+
return `[${ts()}] ${clip(ctx.username ?? "anon")} → /api/task → ${clip(b.alias)}: REJECTED ${ctx.status} ${clip(ctx.error)} (net=${clip(b.network_id)}${crid})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Logs one line for a non-2xx response and returns the response unchanged. */
|
|
41
|
+
export async function logTaskRejection(res: Response, username: string | null | undefined, body: unknown): Promise<Response> {
|
|
42
|
+
if (res.status >= 200 && res.status < 300) return res;
|
|
43
|
+
let error: unknown = null;
|
|
44
|
+
try {
|
|
45
|
+
error = ((await res.clone().json()) as any)?.error;
|
|
46
|
+
} catch {}
|
|
47
|
+
console.log(formatTaskRejection({ status: res.status, error: error ?? "unknown", username: username || "anon", body }));
|
|
48
|
+
return res;
|
|
49
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1034,7 +1034,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1034
1034
|
`SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
|
|
1035
1035
|
channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
|
|
1036
1036
|
disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
|
|
1037
|
-
process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
|
|
1037
|
+
process_uptime_seconds, process_in_flight_count, external_schedules, registered_at,
|
|
1038
|
+
rules_file_capable, skills_capable, files_capable, logs_capable
|
|
1038
1039
|
FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
|
|
1039
1040
|
ORDER BY updated_at DESC LIMIT 1`,
|
|
1040
1041
|
effectiveAlias, resume_id, sessionNetId,
|
|
@@ -1104,6 +1105,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1104
1105
|
handover.registered_at ?? null],
|
|
1105
1106
|
);
|
|
1106
1107
|
}
|
|
1108
|
+
// #550 — the sticky capability flags survive a session handover. The
|
|
1109
|
+
// DELETE+INSERT above starts the new resume_id row at the column default
|
|
1110
|
+
// (0), so a node whose MCP channel reconnected under a new resume_id lost
|
|
1111
|
+
// its rules-file / skills / files / logs doorbell until it next reported
|
|
1112
|
+
// the flag (board #548). Carry each flag that was 1 on the replaced row —
|
|
1113
|
+
// only-ever-set-to-1, and under the same binding as setting it: a node
|
|
1114
|
+
// token bound to this alias. Fixed statement, every parameter used (PG).
|
|
1115
|
+
if (handover && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1116
|
+
db.run(
|
|
1117
|
+
`UPDATE sessions SET
|
|
1118
|
+
rules_file_capable = CASE WHEN ?2 = 1 THEN 1 ELSE rules_file_capable END,
|
|
1119
|
+
skills_capable = CASE WHEN ?3 = 1 THEN 1 ELSE skills_capable END,
|
|
1120
|
+
files_capable = CASE WHEN ?4 = 1 THEN 1 ELSE files_capable END,
|
|
1121
|
+
logs_capable = CASE WHEN ?5 = 1 THEN 1 ELSE logs_capable END
|
|
1122
|
+
WHERE resume_id = ?1`,
|
|
1123
|
+
[resume_id,
|
|
1124
|
+
Number(handover.rules_file_capable) === 1 ? 1 : 0, Number(handover.skills_capable) === 1 ? 1 : 0,
|
|
1125
|
+
Number(handover.files_capable) === 1 ? 1 : 0, Number(handover.logs_capable) === 1 ? 1 : 0],
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1107
1128
|
// app#225 follow-up — separate statement on purpose: the INSERT above is
|
|
1108
1129
|
// byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
|
|
1109
1130
|
// and only by a node token bound to this alias (a user token or another
|