@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.75
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-acl-http.test.ts +2 -0
- package/src/auth.ts +16 -7
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/http-gzip.test.ts +21 -0
- package/src/http-gzip.ts +54 -4
- package/src/human-dm.ts +35 -8
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +73 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-http.test.ts +3 -2
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +15 -0
- package/src/requirements-migrate.ts +81 -0
- package/src/requirements-seq-http.test.ts +2 -1
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-migration.test.ts +13 -0
- package/src/requirements.ts +341 -27
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/route-timing.test.ts +40 -0
- package/src/route-timing.ts +70 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/server.ts +95 -12
- package/src/task-access-http.test.ts +375 -0
- package/src/task-access.ts +284 -0
- package/src/tools.ts +52 -9
- package/src/uploads.ts +8 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { __resetRouteTimingForTest, recordRouteTiming, routeKey, routeStats, ROUTE_RING_SIZE } from "./route-timing";
|
|
3
|
+
|
|
4
|
+
const u = (s: string) => new URL(`http://hub${s}`);
|
|
5
|
+
|
|
6
|
+
test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
|
|
7
|
+
expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
|
|
8
|
+
expect(routeKey("GET", u("/api/requirements?view=summary&network_id=n"))).toBe("GET /api/requirements?view=summary");
|
|
9
|
+
expect(routeKey("PATCH", u("/api/requirements/req_3f9a/checklist/c1"))).toBe("PATCH /api/requirements/:id/checklist/:id");
|
|
10
|
+
expect(routeKey("GET", u("/api/requirements/stats"))).toBe("GET /api/requirements/stats");
|
|
11
|
+
expect(routeKey("GET", u("/api/status?light=1"))).toBe("GET /api/status?light=1");
|
|
12
|
+
expect(routeKey("GET", u(`/api/nodes/${encodeURIComponent("通信牛")}/config`))).toBe("GET /api/nodes/:id/config");
|
|
13
|
+
expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("routeStats groups the window, sorts by total time, and the ring keeps only the newest samples", () => {
|
|
17
|
+
__resetRouteTimingForTest();
|
|
18
|
+
const now = 1_000_000;
|
|
19
|
+
const req = (path: string) => new Request(`http://hub${path}`);
|
|
20
|
+
const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
|
|
21
|
+
const at = (ms: number) => performance.now() - ms;
|
|
22
|
+
recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
|
|
23
|
+
recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
|
|
24
|
+
recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
|
|
25
|
+
recordRouteTiming(req("/api/status?light=1"), at(100), res(), now - 10 * 60_000); // 窗口外
|
|
26
|
+
expect(recordRouteTiming(req("/ws"), 0, undefined, now)).toBeUndefined();
|
|
27
|
+
const stats = routeStats(60_000, now);
|
|
28
|
+
expect(stats.samples).toBe(3);
|
|
29
|
+
expect(stats.routes.map(r => r.route)).toEqual(["GET /api/requirements", "GET /api/status?light=1"]);
|
|
30
|
+
const r0 = stats.routes[0];
|
|
31
|
+
expect(r0.count).toBe(2);
|
|
32
|
+
expect(r0.bytes).toBe(1500);
|
|
33
|
+
expect(r0.max_ms).toBeGreaterThanOrEqual(29);
|
|
34
|
+
expect(r0.total_ms).toBeGreaterThanOrEqual(39);
|
|
35
|
+
expect(stats.routes[1].errors).toBe(1);
|
|
36
|
+
for (let i = 0; i < ROUTE_RING_SIZE + 10; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
|
|
37
|
+
const after = routeStats(60_000, now);
|
|
38
|
+
expect(after.samples).toBe(ROUTE_RING_SIZE);
|
|
39
|
+
expect(after.routes.map(r => r.route)).toEqual(["GET /api/tasks"]);
|
|
40
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 每个路由的耗时环(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
|
|
2
|
+
// 说不清是谁在吃 CPU。这里在 fetch 入口记最近 ROUTE_RING_SIZE 个请求(路由形状、耗时、状态、字节),
|
|
3
|
+
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者。
|
|
4
|
+
export const ROUTE_RING_SIZE = 4096;
|
|
5
|
+
|
|
6
|
+
type Sample = { at: number; route: string; ms: number; status: number; bytes: number | null };
|
|
7
|
+
const ring: Sample[] = [];
|
|
8
|
+
let next = 0;
|
|
9
|
+
|
|
10
|
+
/** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
|
|
11
|
+
const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
|
|
12
|
+
export function routeKey(method: string, url: URL): string {
|
|
13
|
+
const segments = url.pathname.split("/").map((seg, i) => {
|
|
14
|
+
if (i < 2 || !seg) return seg;
|
|
15
|
+
return /[0-9%]/.test(seg) || /[^\x20-\x7e]/.test(seg) || seg.length > 24 ? ":id" : seg;
|
|
16
|
+
});
|
|
17
|
+
const shape = SHAPE_PARAMS.filter(k => url.searchParams.has(k)).map(k => `${k}=${url.searchParams.get(k)}`).join("&");
|
|
18
|
+
return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
|
|
22
|
+
export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
|
|
23
|
+
if (!res) return res;
|
|
24
|
+
let url: URL;
|
|
25
|
+
try { url = new URL(req.url); } catch { return res; }
|
|
26
|
+
const length = Number(res.headers.get("content-length"));
|
|
27
|
+
const sample: Sample = { at: now, route: routeKey(req.method, url), ms: performance.now() - started, status: res.status, bytes: Number.isFinite(length) && res.headers.has("content-length") ? length : null };
|
|
28
|
+
if (ring.length < ROUTE_RING_SIZE) ring.push(sample);
|
|
29
|
+
else ring[next] = sample;
|
|
30
|
+
next = (next + 1) % ROUTE_RING_SIZE;
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
|
|
35
|
+
|
|
36
|
+
/** 最近 windowMs 内的样本按路由汇总,按总耗时降序。 */
|
|
37
|
+
export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
|
|
38
|
+
const since = now - windowMs;
|
|
39
|
+
const groups = new Map<string, Sample[]>();
|
|
40
|
+
let oldest: number | null = null;
|
|
41
|
+
for (const s of ring) {
|
|
42
|
+
if (s.at < since) continue;
|
|
43
|
+
if (oldest === null || s.at < oldest) oldest = s.at;
|
|
44
|
+
let g = groups.get(s.route);
|
|
45
|
+
if (!g) groups.set(s.route, g = []);
|
|
46
|
+
g.push(s);
|
|
47
|
+
}
|
|
48
|
+
const round = (n: number) => Math.round(n * 10) / 10;
|
|
49
|
+
const routes = [...groups].map(([route, samples]): RouteStat => {
|
|
50
|
+
const ms = samples.map(s => s.ms).sort((a, b) => a - b);
|
|
51
|
+
const total = ms.reduce((a, b) => a + b, 0);
|
|
52
|
+
return {
|
|
53
|
+
route,
|
|
54
|
+
count: samples.length,
|
|
55
|
+
total_ms: round(total),
|
|
56
|
+
avg_ms: round(total / samples.length),
|
|
57
|
+
p95_ms: round(ms[Math.min(ms.length - 1, Math.floor(ms.length * 0.95))]),
|
|
58
|
+
max_ms: round(ms[ms.length - 1]),
|
|
59
|
+
bytes: samples.reduce((n, s) => n + (s.bytes ?? 0), 0),
|
|
60
|
+
errors: samples.filter(s => s.status >= 500).length,
|
|
61
|
+
};
|
|
62
|
+
}).sort((a, b) => b.total_ms - a.total_ms);
|
|
63
|
+
return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test-only. */
|
|
67
|
+
export function __resetRouteTimingForTest(): void {
|
|
68
|
+
ring.length = 0;
|
|
69
|
+
next = 0;
|
|
70
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// 定时任务的回复要进建排程那个人的未读(Vincent 2026-09-30「定时任务触发的…那个红点它是不显示的」)。
|
|
2
|
+
//
|
|
3
|
+
// scheduler 派的任务 from_name='scheduler',回复以前按 from_name 落进 inbox(session_name='scheduler'):
|
|
4
|
+
// app 会话里看得到(会话取自 tasks 表),但 unread_by_agent 只数 session_name=用户名 的行 ⇒ 永远没红点。
|
|
5
|
+
// 这里走真 Hub(bootServer)+ 真节点令牌回复路径(send_reply,callerTokenIsNetwork=true)。
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
11
|
+
import { registerTools } from "./tools.js";
|
|
12
|
+
|
|
13
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
14
|
+
if (!activeDbPath) throw new Error("scheduled-reply-unread requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
15
|
+
|
|
16
|
+
let server: any;
|
|
17
|
+
let base = "";
|
|
18
|
+
let ownerToken = "";
|
|
19
|
+
let ownerName = "";
|
|
20
|
+
let memberToken = "";
|
|
21
|
+
let memberName = "";
|
|
22
|
+
let networkId = "";
|
|
23
|
+
let ownerId = "";
|
|
24
|
+
const stamp = Date.now();
|
|
25
|
+
const ALIAS = `sched-reply-node-${stamp}`;
|
|
26
|
+
const nodeId = `n_sched_reply_${stamp}`;
|
|
27
|
+
|
|
28
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
29
|
+
const res = await fetch(`${base}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
32
|
+
});
|
|
33
|
+
return { status: res.status, body: await res.json() as any };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 节点令牌身份调 send_reply(与真节点的 commhub_reply 同一个 handler、同一组归属校验)。 */
|
|
37
|
+
async function nodeReply(args: Record<string, unknown>): Promise<any> {
|
|
38
|
+
const mcp = new McpServer({ name: "sched-reply", version: "0" }) as any;
|
|
39
|
+
const handlers: Record<string, (a: any) => Promise<any>> = {};
|
|
40
|
+
const original = mcp.tool.bind(mcp);
|
|
41
|
+
mcp.tool = (name: string, ...rest: any[]) => {
|
|
42
|
+
const handler = rest.at(-1);
|
|
43
|
+
if (typeof handler === "function") handlers[name] = handler;
|
|
44
|
+
return original(name, ...rest);
|
|
45
|
+
};
|
|
46
|
+
registerTools(mcp, undefined, networkId, ownerId, ALIAS, true, null);
|
|
47
|
+
const out = await handlers.send_reply(args);
|
|
48
|
+
return JSON.parse(out.content[0].text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function unreadFor(token: string): Promise<Record<string, number>> {
|
|
52
|
+
const res = await api(token, "/api/messages?scope=user&limit=50");
|
|
53
|
+
expect(res.status).toBe(200);
|
|
54
|
+
return res.body.unread_by_agent ?? {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function ackAgent(token: string) {
|
|
58
|
+
const res = await api(token, "/api/messages/ack", { method: "POST", body: JSON.stringify({ agent: ALIAS }) });
|
|
59
|
+
expect(res.status).toBe(200);
|
|
60
|
+
return res.body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 建一个排程、让它立刻到期、跑一次,返回它派出的 task_id。 */
|
|
64
|
+
async function fireSchedule(token: string, name: string): Promise<{ scheduleId: string; taskId: string }> {
|
|
65
|
+
const created = await api(token, "/api/scheduled-tasks", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
network_id: networkId, name, target_node_id: nodeId, task: `${name} tick`,
|
|
69
|
+
timezone: "UTC", schedule: { type: "interval", every_seconds: 120 },
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
expect(created.status).toBe(201);
|
|
73
|
+
const scheduleId = created.body.schedule.schedule_id as string;
|
|
74
|
+
// 别的排程不许在这次 sweep 里一起跑
|
|
75
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 24 * 3600_000).toISOString(), scheduleId]);
|
|
76
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
|
|
77
|
+
await runDueScheduledTasks();
|
|
78
|
+
const run = db.get<{ task_id: string | null }>(
|
|
79
|
+
"SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY scheduled_for DESC LIMIT 1", scheduleId,
|
|
80
|
+
);
|
|
81
|
+
expect(run?.task_id).toBeTruthy();
|
|
82
|
+
const task = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE task_id = ?1", run!.task_id!);
|
|
83
|
+
expect(task?.from_name).toBe("scheduler");
|
|
84
|
+
return { scheduleId, taskId: run!.task_id! };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function replyRow(taskId: string) {
|
|
88
|
+
return db.get<{ session_name: string; from_session: string; acked: number }>(
|
|
89
|
+
"SELECT session_name, from_session, acked FROM inbox WHERE type = 'reply' AND in_reply_to = ?1", taskId,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeAll(async () => {
|
|
94
|
+
ownerName = `sched_reply_owner_${stamp}`;
|
|
95
|
+
const owner = register(ownerName, "SchedReplyOwner123!", undefined, "seed");
|
|
96
|
+
expect(owner.ok).toBe(true);
|
|
97
|
+
ownerToken = owner.token!;
|
|
98
|
+
networkId = owner.network_id!;
|
|
99
|
+
ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
100
|
+
|
|
101
|
+
memberName = `sched_reply_member_${stamp}`;
|
|
102
|
+
const member = register(memberName, "SchedReplyMember123!", undefined, "seed");
|
|
103
|
+
expect(member.ok).toBe(true);
|
|
104
|
+
memberToken = member.token!;
|
|
105
|
+
const memberId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", memberName)!.user_id;
|
|
106
|
+
expect(addNetworkMember(networkId, memberId, "member", ownerId, { agentAccess: "all" }).ok).toBe(true);
|
|
107
|
+
|
|
108
|
+
expect(createNetworkTokenForNode(ownerId, networkId, ALIAS).ok).toBe(true);
|
|
109
|
+
db.run(
|
|
110
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
|
|
111
|
+
[nodeId, ALIAS, networkId],
|
|
112
|
+
);
|
|
113
|
+
db.run(
|
|
114
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)",
|
|
115
|
+
[`r_sched_reply_${stamp}`, ALIAS, nodeId, networkId],
|
|
116
|
+
);
|
|
117
|
+
const mod: any = await import("./server.js");
|
|
118
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
119
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
120
|
+
}, 30_000);
|
|
121
|
+
|
|
122
|
+
afterAll(() => {
|
|
123
|
+
try { server?.stop?.(true); } catch {}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("scheduled-task replies reach the schedule creator's unread", () => {
|
|
127
|
+
test("a direct reply still lands on the sender and counts once", async () => {
|
|
128
|
+
await ackAgent(ownerToken);
|
|
129
|
+
const sent = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify({ network_id: networkId, alias: ALIAS, task: "direct hello" }) });
|
|
130
|
+
expect(sent.status).toBe(200);
|
|
131
|
+
const taskId = sent.body.task_id as string;
|
|
132
|
+
const reply = await nodeReply({ in_reply_to: taskId, text: "direct answer", status: "replied" });
|
|
133
|
+
expect(reply.ok).toBe(true);
|
|
134
|
+
expect(replyRow(taskId)?.session_name).toBe(ownerName);
|
|
135
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
136
|
+
await ackAgent(ownerToken);
|
|
137
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a scheduled-task reply counts for the creator, and ack-by-agent clears it", async () => {
|
|
141
|
+
await ackAgent(ownerToken);
|
|
142
|
+
const { taskId } = await fireSchedule(ownerToken, "owner-every-2m");
|
|
143
|
+
// 节点不带 alias(SDK 运行时)——收件人由 in_reply_to 推出
|
|
144
|
+
const reply = await nodeReply({ in_reply_to: taskId, text: "tick answer", status: "replied" });
|
|
145
|
+
expect(reply.ok).toBe(true);
|
|
146
|
+
expect(replyRow(taskId)).toEqual({ session_name: ownerName, from_session: ALIAS, acked: 0 });
|
|
147
|
+
expect(db.get<{ status: string; result: string }>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId))
|
|
148
|
+
.toEqual({ status: "replied", result: "tick answer" });
|
|
149
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
150
|
+
|
|
151
|
+
const acked = await ackAgent(ownerToken);
|
|
152
|
+
expect(acked.acked_inbox).toBe(1);
|
|
153
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
154
|
+
expect(replyRow(taskId)?.acked).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a node that names alias='scheduler' explicitly is routed the same way", async () => {
|
|
158
|
+
await ackAgent(ownerToken);
|
|
159
|
+
const { taskId } = await fireSchedule(ownerToken, "owner-explicit-alias");
|
|
160
|
+
const reply = await nodeReply({ alias: "scheduler", in_reply_to: taskId, text: "explicit answer", status: "replied" });
|
|
161
|
+
expect(reply.ok).toBe(true);
|
|
162
|
+
expect(replyRow(taskId)?.session_name).toBe(ownerName);
|
|
163
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
164
|
+
await ackAgent(ownerToken);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("another member of the network does not get the owner's scheduled reply, and gets their own", async () => {
|
|
168
|
+
await ackAgent(ownerToken);
|
|
169
|
+
await ackAgent(memberToken);
|
|
170
|
+
const ownerRun = await fireSchedule(ownerToken, "owner-only");
|
|
171
|
+
expect((await nodeReply({ in_reply_to: ownerRun.taskId, text: "for owner", status: "replied" })).ok).toBe(true);
|
|
172
|
+
expect((await unreadFor(memberToken))[ALIAS]).toBeUndefined();
|
|
173
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
174
|
+
|
|
175
|
+
const memberRun = await fireSchedule(memberToken, "member-only");
|
|
176
|
+
expect((await nodeReply({ in_reply_to: memberRun.taskId, text: "for member", status: "replied" })).ok).toBe(true);
|
|
177
|
+
expect(replyRow(memberRun.taskId)?.session_name).toBe(memberName);
|
|
178
|
+
expect((await unreadFor(memberToken))[ALIAS]).toBe(1);
|
|
179
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
|
|
180
|
+
await ackAgent(ownerToken);
|
|
181
|
+
await ackAgent(memberToken);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a schedule with no creator falls back to 'scheduler'", async () => {
|
|
185
|
+
await ackAgent(ownerToken);
|
|
186
|
+
const { scheduleId, taskId } = await fireSchedule(ownerToken, "creatorless");
|
|
187
|
+
db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [scheduleId]);
|
|
188
|
+
expect((await nodeReply({ in_reply_to: taskId, text: "nobody's", status: "replied" })).ok).toBe(true);
|
|
189
|
+
expect(replyRow(taskId)?.session_name).toBe("scheduler");
|
|
190
|
+
expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a creator whose username is also a node alias falls back to 'scheduler' (the collision guard)", async () => {
|
|
194
|
+
await ackAgent(ownerToken);
|
|
195
|
+
const { taskId } = await fireSchedule(ownerToken, "colliding-creator");
|
|
196
|
+
const clashNodeId = `n_clash_${stamp}`;
|
|
197
|
+
db.run(
|
|
198
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
|
|
199
|
+
[clashNodeId, ownerName, networkId],
|
|
200
|
+
);
|
|
201
|
+
try {
|
|
202
|
+
expect((await nodeReply({ in_reply_to: taskId, text: "not a todo", status: "replied" })).ok).toBe(true);
|
|
203
|
+
// 那个同名节点收不到这条(它不是节点的待办),用户名也拿不到(inbox 半边整体跳过)
|
|
204
|
+
expect(replyRow(taskId)?.session_name).toBe("scheduler");
|
|
205
|
+
expect(db.get<{ n: number }>(
|
|
206
|
+
"SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND in_reply_to = ?2", ownerName, taskId,
|
|
207
|
+
)?.n).toBe(0);
|
|
208
|
+
} finally {
|
|
209
|
+
db.run("DELETE FROM nodes WHERE node_id = ?1", [clashNodeId]);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -15,7 +15,8 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
15
15
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
16
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
17
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
18
|
-
import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
18
|
+
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
19
|
+
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
19
20
|
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
20
21
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
21
22
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
stripHostLocalPathsForCrossHostSafe,
|
|
42
43
|
validateAttachments,
|
|
43
44
|
validateIndexEntry,
|
|
45
|
+
DM_FILE_SCOPE,
|
|
44
46
|
} from "./uploads.js";
|
|
45
47
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
46
48
|
import { dirname as pathDirname, join as pathJoin } from "path";
|
|
@@ -59,6 +61,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
|
|
|
59
61
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
60
62
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
61
63
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
64
|
+
import { recordRouteTiming, routeStats } from "./route-timing.js";
|
|
62
65
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
63
66
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
64
67
|
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
@@ -385,15 +388,16 @@ export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}
|
|
|
385
388
|
// these two fields lives here and nowhere else, so the authz rules can be
|
|
386
389
|
// read as rules rather than as string-checks.
|
|
387
390
|
export function normalizeEntry(
|
|
388
|
-
entry: { owner_id?: unknown; network_id?: unknown },
|
|
389
|
-
): { ownerId: string | null; networkId: string | null } {
|
|
391
|
+
entry: { owner_id?: unknown; network_id?: unknown; scope?: unknown },
|
|
392
|
+
): { ownerId: string | null; networkId: string | null; dmScoped?: true } {
|
|
390
393
|
const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
|
|
391
394
|
? entry.owner_id
|
|
392
395
|
: null;
|
|
393
396
|
const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
|
|
394
397
|
? entry.network_id
|
|
395
398
|
: null;
|
|
396
|
-
|
|
399
|
+
// dmScoped 只在是私信文件时出现:其余条目的形状与之前逐字相同。
|
|
400
|
+
return { ownerId, networkId, ...(entry.scope === DM_FILE_SCOPE ? { dmScoped: true as const } : {}) };
|
|
397
401
|
}
|
|
398
402
|
|
|
399
403
|
// #495/#503 — shared authorization gate for /api/files/:file_id downloads.
|
|
@@ -414,6 +418,13 @@ export function normalizeEntry(
|
|
|
414
418
|
// a task but not open its attachment is a broken product).
|
|
415
419
|
// - entry.network_id absent (legacy / unattributed) → the pre-#503
|
|
416
420
|
// rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
|
|
421
|
+
// - entry.dmScoped (uploaded with ?purpose=dm — its first use is a
|
|
422
|
+
// human DM) → only the uploader and the users on either side of a DM
|
|
423
|
+
// that carries it (human-dm.ts dmParticipantSeesFile). Other members of
|
|
424
|
+
// the network and node tokens (agents) are denied. admin-utok and the
|
|
425
|
+
// legacy master token still pass above, as for every other file.
|
|
426
|
+
// Files uploaded any other way never become DM-scoped, including when
|
|
427
|
+
// they are later forwarded into a DM.
|
|
417
428
|
// - Everything else → deny (404). New uploads in production always
|
|
418
429
|
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
419
430
|
// producing path when DEV_OPEN=off and no legacy master token is
|
|
@@ -428,7 +439,7 @@ export function normalizeEntry(
|
|
|
428
439
|
// 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
|
|
429
440
|
export function authorizeFileDownload(
|
|
430
441
|
principal: Principal,
|
|
431
|
-
entry: { ownerId: string | null; networkId: string | null },
|
|
442
|
+
entry: { ownerId: string | null; networkId: string | null; dmScoped?: boolean },
|
|
432
443
|
fileId?: string,
|
|
433
444
|
): boolean {
|
|
434
445
|
if (principal.kind === "legacy-master") return true;
|
|
@@ -436,8 +447,13 @@ export function authorizeFileDownload(
|
|
|
436
447
|
|
|
437
448
|
if (entry.networkId !== null) {
|
|
438
449
|
if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
|
|
439
|
-
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
|
|
450
|
+
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId && !entry.dmScoped;
|
|
440
451
|
if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
|
|
452
|
+
// 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
|
|
453
|
+
if (entry.dmScoped) {
|
|
454
|
+
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
455
|
+
return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
|
|
456
|
+
}
|
|
441
457
|
// 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
|
|
442
458
|
if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
|
|
443
459
|
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
@@ -888,7 +904,8 @@ return Bun.serve({
|
|
|
888
904
|
|
|
889
905
|
async fetch(req, server) {
|
|
890
906
|
// 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
|
|
891
|
-
|
|
907
|
+
// 2026-09-30 —— 每个请求的路由形状 + 耗时进内存环(route-timing.ts),GET /api/stats/routes 看谁在吃 CPU。
|
|
908
|
+
return recordRouteTiming(req, performance.now(), await maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
|
|
892
909
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
893
910
|
|
|
894
911
|
// ── CORS preflight ──
|
|
@@ -1548,6 +1565,51 @@ return Bun.serve({
|
|
|
1548
1565
|
// ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
|
|
1549
1566
|
// GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
|
|
1550
1567
|
// PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
|
|
1568
|
+
// ── 任务(需求卡)的人员权限(RFC-038 §9):成员能看 / 能改哪些卡。owner/admin/Hub 管理员管理 ──
|
|
1569
|
+
// GET → { task_access: 'all'|'scoped', restricted, project_grants: [{project_id, can_edit}] }
|
|
1570
|
+
// PUT { task_access?, project_grants?: [{project_id, can_edit?}] | string[] } → 不传的字段保持原样;传了整体替换
|
|
1571
|
+
const taskGrantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/task-grants$/);
|
|
1572
|
+
if (taskGrantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1573
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1574
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1575
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1576
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1577
|
+
const netId = decodeURIComponent(taskGrantsMatch[1]);
|
|
1578
|
+
const targetUid = decodeURIComponent(taskGrantsMatch[2]);
|
|
1579
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1580
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1581
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1582
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1583
|
+
}
|
|
1584
|
+
const targetRole = getUserNetworkRole(targetUid, netId);
|
|
1585
|
+
if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
|
|
1586
|
+
const view = () => ({
|
|
1587
|
+
ok: true, network_id: netId, user_id: targetUid, role: targetRole,
|
|
1588
|
+
task_access: getTaskAccessMode(netId, targetUid), restricted: isTaskScoped(targetUid, netId),
|
|
1589
|
+
project_grants: listProjectGrants(netId, targetUid),
|
|
1590
|
+
});
|
|
1591
|
+
if (req.method === "GET") return withCors(req, Response.json(view()));
|
|
1592
|
+
// 网络 admin 不能改 owner / 其他 admin(对他们本来也不生效)。
|
|
1593
|
+
if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
|
|
1594
|
+
return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1595
|
+
}
|
|
1596
|
+
let body: any;
|
|
1597
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1598
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1599
|
+
if (body.task_access === undefined && body.project_grants === undefined) {
|
|
1600
|
+
return withCors(req, Response.json({ ok: false, error: "task_access or project_grants required" }, { status: 400 }));
|
|
1601
|
+
}
|
|
1602
|
+
const before = { task_access: getTaskAccessMode(netId, targetUid), project_grants: listProjectGrants(netId, targetUid) };
|
|
1603
|
+
const result = replaceTaskGrants({ networkId: netId, userId: targetUid, taskAccess: body.task_access, projectGrants: body.project_grants, actorUserId: resolved.user.user_id });
|
|
1604
|
+
if (!result.ok) {
|
|
1605
|
+
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1606
|
+
}
|
|
1607
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_task_grants_changed", "network", netId,
|
|
1608
|
+
JSON.stringify({ user_id: targetUid, before, after: { task_access: result.task_access, project_grants: result.project_grants } }).slice(0, 4000),
|
|
1609
|
+
undefined, netId);
|
|
1610
|
+
return withCors(req, Response.json(view()));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1551
1613
|
const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
|
|
1552
1614
|
if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1553
1615
|
const token = requestToken(req, { allowQueryToken: false });
|
|
@@ -1648,8 +1710,12 @@ return Bun.serve({
|
|
|
1648
1710
|
if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
|
|
1649
1711
|
return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
|
|
1650
1712
|
}
|
|
1651
|
-
|
|
1652
|
-
if (
|
|
1713
|
+
// task_access 缺省 NEW_MEMBER_TASK_ACCESS(RFC-038 §9.4);显式传 'all' / 'scoped' 覆盖。
|
|
1714
|
+
if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
|
|
1715
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
|
|
1716
|
+
}
|
|
1717
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
|
|
1718
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? NEW_MEMBER_TASK_ACCESS}`, undefined, netId);
|
|
1653
1719
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1654
1720
|
}
|
|
1655
1721
|
if (req.method === "PUT" && targetUid) {
|
|
@@ -1657,7 +1723,7 @@ return Bun.serve({
|
|
|
1657
1723
|
const body = await req.json() as any;
|
|
1658
1724
|
const result = updateMemberRole(netId, targetUid, body.role);
|
|
1659
1725
|
if (result.ok) {
|
|
1660
|
-
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}
|
|
1726
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`, undefined, netId);
|
|
1661
1727
|
closeUserStreamsInNetwork(netId, targetUid);
|
|
1662
1728
|
}
|
|
1663
1729
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1666,7 +1732,7 @@ return Bun.serve({
|
|
|
1666
1732
|
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1667
1733
|
const result = removeNetworkMember(netId, targetUid);
|
|
1668
1734
|
if (result.ok) {
|
|
1669
|
-
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
|
|
1735
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid, undefined, netId);
|
|
1670
1736
|
closeUserStreamsInNetwork(netId, targetUid);
|
|
1671
1737
|
}
|
|
1672
1738
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -2062,6 +2128,16 @@ return Bun.serve({
|
|
|
2062
2128
|
});
|
|
2063
2129
|
if (requirementsResponse) return withCors(req, requirementsResponse);
|
|
2064
2130
|
|
|
2131
|
+
// ── REST: per-route timing (ops-only, same gate as /api/stats/sse) ──
|
|
2132
|
+
// ?minutes=N(默认 15,最多 1440):最近 N 分钟里每个路由的次数、总耗时、p95、最大、字节。
|
|
2133
|
+
if (url.pathname === "/api/stats/routes" && req.method === "GET") {
|
|
2134
|
+
if (restAuth && !isAdmin) {
|
|
2135
|
+
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
2136
|
+
}
|
|
2137
|
+
const minutes = Math.min(Math.max(Number(url.searchParams.get("minutes")) || 15, 1), 1440);
|
|
2138
|
+
return withCors(req, Response.json({ ok: true, ...routeStats(minutes * 60_000) }));
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2065
2141
|
// ── #473 REST: SSE connection detail (ops-only) ──
|
|
2066
2142
|
// The per-key breakdown /health used to expose anonymously: keys are
|
|
2067
2143
|
// `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
|
|
@@ -2454,6 +2530,12 @@ return Bun.serve({
|
|
|
2454
2530
|
// uploaded and never gets their multipart envelope parsed.
|
|
2455
2531
|
const principal = resolvePrincipal(req);
|
|
2456
2532
|
const requestedNetId = url.searchParams.get("network_id");
|
|
2533
|
+
// ?purpose=dm:这个文件是为一条人与人私信传的(见 UploadIndexEntry.scope)。缺省 = 旧行为。
|
|
2534
|
+
// 拼错的值拒掉,不静默当成缺省 —— 否则私信图会不声不响地变成全网可见。
|
|
2535
|
+
const purpose = url.searchParams.get("purpose");
|
|
2536
|
+
if (purpose !== null && purpose !== DM_FILE_SCOPE) {
|
|
2537
|
+
return earlyReject(Response.json({ ok: false, error: "bad_purpose", message: `purpose must be "${DM_FILE_SCOPE}" or absent` }, { status: 400 }));
|
|
2538
|
+
}
|
|
2457
2539
|
let uploadNetId: string | null = null;
|
|
2458
2540
|
|
|
2459
2541
|
switch (principal.kind) {
|
|
@@ -2630,6 +2712,7 @@ return Bun.serve({
|
|
|
2630
2712
|
owner_id: authCtx?.userId ?? null,
|
|
2631
2713
|
// Key omitted (not null) when unattributed — see UploadIndexEntry.
|
|
2632
2714
|
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
2715
|
+
...(purpose === DM_FILE_SCOPE ? { scope: DM_FILE_SCOPE } : {}),
|
|
2633
2716
|
uploaded_at: new Date().toISOString(),
|
|
2634
2717
|
}, null, 2));
|
|
2635
2718
|
} else {
|
|
@@ -4164,7 +4247,7 @@ Security: ${SECURITY_LABEL}
|
|
|
4164
4247
|
`,
|
|
4165
4248
|
{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
|
|
4166
4249
|
));
|
|
4167
|
-
})()));
|
|
4250
|
+
})())));
|
|
4168
4251
|
},
|
|
4169
4252
|
|
|
4170
4253
|
// ── WebSocket handler for tmux terminal streaming ──
|