@sleep2agi/commhub-server 0.9.0-preview.102 → 0.9.0-preview.104
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 +142 -4
- package/src/agent-acl-slice3-http.test.ts +5 -5
- package/src/network-scope.ts +52 -0
- package/src/requirement-due-reminders-http.test.ts +103 -1
- package/src/requirement-due-reminders.ts +59 -12
- package/src/restricted-files.ts +9 -6
- package/src/server.ts +37 -12
- package/src/tools.ts +4 -4
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.104",
|
|
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",
|
|
@@ -321,13 +321,15 @@ describe("granted member sees and messages ONLY what was granted", () => {
|
|
|
321
321
|
expect((await tool(aliceToken, "send_task", { alias: X.alias, task: "mcp hello X", network_id: NET })).ok).toBe(true);
|
|
322
322
|
expect((await tool(aliceToken, "send_task", { alias: Z.alias, task: "hi", network_id: NET })).error).toBe("agent_not_granted");
|
|
323
323
|
});
|
|
324
|
-
test("tasks: alice sees
|
|
324
|
+
test("#563 tasks: alice sees X's and Y's whole timeline (incl. admin's history), never Z's", async () => {
|
|
325
325
|
seedTask("t_x_to_alice", X.alias, "acl_alice", "X answers alice");
|
|
326
326
|
const ids = (await get(aliceToken, `/api/tasks?network_id=${NET}`)).body.tasks.map((t: any) => t.content).sort();
|
|
327
|
-
|
|
328
|
-
expect((
|
|
327
|
+
// 与 admin 看同一个节点时看到的一样:admin 发给 X / Y(Y 是只读授权)的历史也在。
|
|
328
|
+
expect(ids).toEqual(["X answers alice", "hello X", "mcp hello X", "admin secret for X", "admin secret for Y"].sort());
|
|
329
|
+
expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).status).toBe(200);
|
|
329
330
|
expect((await get(aliceToken, `/api/tasks/t_x_to_alice`)).status).toBe(200);
|
|
330
|
-
expect((await tool(aliceToken, "list_tasks", { network_id: NET })).tasks.map((t: any) => t.content)).
|
|
331
|
+
expect((await tool(aliceToken, "list_tasks", { network_id: NET })).tasks.map((t: any) => t.content)).toContain("admin secret for X");
|
|
332
|
+
expect((await tool(aliceToken, "get_task", { task_id: "t_admin_to_x" })).ok).toBe(true);
|
|
331
333
|
});
|
|
332
334
|
test("requirements people now include X and Y nodes but not Z", async () => {
|
|
333
335
|
const nodes = (await get(aliceToken, `/api/requirements/people?network_id=${NET}`)).body.people.filter((p: any) => p.kind === "node").map((p: any) => p.id).sort();
|
|
@@ -341,6 +343,142 @@ describe("granted member sees and messages ONLY what was granted", () => {
|
|
|
341
343
|
});
|
|
342
344
|
});
|
|
343
345
|
|
|
346
|
+
describe("#563 one node, one timeline: everyone who can see N sees the same history", () => {
|
|
347
|
+
const contents = async (token: string, q = "") => (await get(token, `/api/tasks?network_id=${NET}${q}`)).body.tasks.map((t: any) => t.content).sort();
|
|
348
|
+
test("seed: bob's history with X and traffic between X and the invisible Z", () => {
|
|
349
|
+
seedTask("t563_bob_to_x", "acl_bob", X.alias, "bob asked X earlier");
|
|
350
|
+
seedTask("t563_z_to_x", Z.alias, X.alias, "Z whispers to X");
|
|
351
|
+
seedTask("t563_x_to_z", X.alias, Z.alias, "X whispers to Z");
|
|
352
|
+
seedTask("t563_admin_to_z", "scheduler", Z.alias, "scheduler pokes Z");
|
|
353
|
+
});
|
|
354
|
+
test("chat window query (to_name=X): alice sees exactly what admin sees, minus rows from an agent she cannot see", async () => {
|
|
355
|
+
const adminView = await contents(adminToken, `&to_name=${X.alias}`);
|
|
356
|
+
const aliceView = await contents(aliceToken, `&to_name=${X.alias}`);
|
|
357
|
+
expect(adminView).toContain("Z whispers to X");
|
|
358
|
+
expect(aliceView).toEqual(adminView.filter((c: string) => c !== "Z whispers to X"));
|
|
359
|
+
expect(aliceView).toContain("admin secret for X");
|
|
360
|
+
expect(aliceView).toContain("bob asked X earlier");
|
|
361
|
+
});
|
|
362
|
+
test("rows between a visible and an invisible agent stay hidden in every direction; Z's timeline stays hidden", async () => {
|
|
363
|
+
const all = await contents(aliceToken);
|
|
364
|
+
expect(all).not.toContain("Z whispers to X");
|
|
365
|
+
expect(all).not.toContain("X whispers to Z");
|
|
366
|
+
expect(all).not.toContain("scheduler pokes Z");
|
|
367
|
+
expect((await get(aliceToken, `/api/tasks/t563_z_to_x`)).status).toBe(404);
|
|
368
|
+
expect((await get(aliceToken, `/api/tasks/t563_x_to_z`)).status).toBe(404);
|
|
369
|
+
expect((await get(aliceToken, `/api/task_events?task_id=t563_z_to_x`)).body.events).toEqual([]);
|
|
370
|
+
});
|
|
371
|
+
test("/api/tasks/:id: another member's task to X is 200 for granted alice, 404 for bob without a grant", async () => {
|
|
372
|
+
expect((await get(aliceToken, `/api/tasks/t563_bob_to_x`)).status).toBe(200);
|
|
373
|
+
expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).body.task.content).toBe("admin secret for X");
|
|
374
|
+
expect((await get(bobToken, `/api/tasks/t_admin_to_x`)).status).toBe(404);
|
|
375
|
+
expect((await get(bobToken, `/api/task_events?task_id=t_admin_to_x`)).body.events).toEqual([]);
|
|
376
|
+
expect(await contents(bobToken)).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
test("network stats count alice's visible timeline", async () => {
|
|
379
|
+
const r = await get(aliceToken, `/api/networks/${NET}`);
|
|
380
|
+
const n = r.body.stats.tasks.reduce((sum: number, row: any) => sum + row.count, 0);
|
|
381
|
+
expect(n).toBe((await contents(aliceToken)).length);
|
|
382
|
+
});
|
|
383
|
+
test("group grant: bob granted a group holding Z sees Z's history (incl. the scheduler row), not X's", async () => {
|
|
384
|
+
const g = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: "acl-563-z", node_ids: [Z.node] });
|
|
385
|
+
expect(g.status).toBe(200);
|
|
386
|
+
const groupId = g.body.group.group_id;
|
|
387
|
+
expect((await send(adminToken, "PUT", grantsPath(bobId), { group_grants: [{ group_id: groupId, can_message: false }] })).status).toBe(200);
|
|
388
|
+
try {
|
|
389
|
+
const bobView = await contents(bobToken);
|
|
390
|
+
expect(bobView).toContain("scheduler pokes Z");
|
|
391
|
+
expect(bobView).not.toContain("admin secret for X");
|
|
392
|
+
expect(bobView).not.toContain("Z whispers to X");
|
|
393
|
+
expect((await get(bobToken, `/api/tasks/t563_admin_to_z`)).status).toBe(200);
|
|
394
|
+
} finally {
|
|
395
|
+
await send(adminToken, "PUT", grantsPath(bobId), { group_grants: [] });
|
|
396
|
+
await send(adminToken, "DELETE", `/api/networks/${NET}/agent-groups/${groupId}`);
|
|
397
|
+
}
|
|
398
|
+
expect(await contents(bobToken)).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
test("agent_access=all and network admin see every row for X (unchanged); network owner without hub-admin too", async () => {
|
|
401
|
+
const adminView = await contents(adminToken, `&to_name=${X.alias}`);
|
|
402
|
+
await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "all" });
|
|
403
|
+
try {
|
|
404
|
+
expect(await contents(bobToken, `&to_name=${X.alias}`)).toEqual(adminView);
|
|
405
|
+
} finally {
|
|
406
|
+
await send(adminToken, "PUT", grantsPath(bobId), { agent_access: "granted" });
|
|
407
|
+
}
|
|
408
|
+
db.run("UPDATE network_members SET role = 'admin' WHERE network_id = ?1 AND user_id = ?2", [NET, bobId]);
|
|
409
|
+
try {
|
|
410
|
+
expect(await contents(bobToken, `&to_name=${X.alias}`)).toEqual(adminView);
|
|
411
|
+
} finally {
|
|
412
|
+
db.run("UPDATE network_members SET role = 'member' WHERE network_id = ?1 AND user_id = ?2", [NET, bobId]);
|
|
413
|
+
}
|
|
414
|
+
db.run("UPDATE users SET role = 'user' WHERE user_id = ?1", [adminId]);
|
|
415
|
+
try {
|
|
416
|
+
expect(await contents(adminToken, `&to_name=${X.alias}`)).toEqual(adminView);
|
|
417
|
+
} finally {
|
|
418
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
419
|
+
}
|
|
420
|
+
expect(await contents(bobToken)).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
test("files: an attachment on admin's message to X opens for alice; on admin's message to Z it does not", async () => {
|
|
423
|
+
const upload = async () => {
|
|
424
|
+
const form = new FormData();
|
|
425
|
+
form.append("file", new Blob([new Uint8Array([5, 6, 3])], { type: "image/png" }), "shot.png");
|
|
426
|
+
const up = await fetch(`${BASE}/api/upload?network_id=${NET}`, { method: "POST", body: form, headers: auth(adminToken) });
|
|
427
|
+
expect(up.status).toBe(200);
|
|
428
|
+
return (await up.json()).file_id as string;
|
|
429
|
+
};
|
|
430
|
+
const toX = await upload();
|
|
431
|
+
const toZ = await upload();
|
|
432
|
+
const adminName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", adminId)!.username;
|
|
433
|
+
seedTask("t563_img_x", adminName, X.alias, "look at this", { attachments: [{ type: "file", file_id: toX }] });
|
|
434
|
+
seedTask("t563_img_z", adminName, Z.alias, "look at this", { attachments: [{ type: "file", file_id: toZ }] });
|
|
435
|
+
try {
|
|
436
|
+
expect((await fetch(`${BASE}/api/files/${toX}`, { headers: auth(aliceToken) })).status).toBe(200);
|
|
437
|
+
expect((await fetch(`${BASE}/api/files/${toZ}`, { headers: auth(aliceToken) })).status).toBe(404);
|
|
438
|
+
expect((await fetch(`${BASE}/api/files/${toX}`, { headers: auth(bobToken) })).status).toBe(404);
|
|
439
|
+
} finally {
|
|
440
|
+
for (const id of ["t563_img_x", "t563_img_z"]) {
|
|
441
|
+
db.run("DELETE FROM tasks WHERE task_id = ?1", [id]);
|
|
442
|
+
db.run("DELETE FROM inbox WHERE task_id = ?1", [id]);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
test("SSE: admin's new message to X and X's reply to admin wake alice's stream; Z traffic never does", async () => {
|
|
447
|
+
const { pushNetworkObserverEvent } = await import("./push.js");
|
|
448
|
+
const adminName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", adminId)!.username;
|
|
449
|
+
const ctrl = new AbortController();
|
|
450
|
+
const res = await fetch(`${BASE}/events/network/${NET}`, { headers: auth(aliceToken), signal: ctrl.signal });
|
|
451
|
+
expect(res.status).toBe(200);
|
|
452
|
+
const reader = res.body!.getReader();
|
|
453
|
+
const decoder = new TextDecoder();
|
|
454
|
+
let buf = "";
|
|
455
|
+
let pending: ReturnType<typeof reader.read> | null = null;
|
|
456
|
+
const readFor = async (ms: number) => {
|
|
457
|
+
const until = Date.now() + ms;
|
|
458
|
+
while (Date.now() < until) {
|
|
459
|
+
pending ??= reader.read();
|
|
460
|
+
const chunk = await Promise.race([pending, new Promise<null>(r => setTimeout(() => r(null), until - Date.now()))]);
|
|
461
|
+
if (!chunk) break;
|
|
462
|
+
pending = null;
|
|
463
|
+
if (chunk.done) break;
|
|
464
|
+
buf += decoder.decode(chunk.value);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
await readFor(150);
|
|
468
|
+
expect((await send(adminToken, "POST", "/api/task", { alias: X.alias, task: "admin live to X", network_id: NET })).status).toBe(200);
|
|
469
|
+
pushNetworkObserverEvent(NET, { type: "new_reply", task_id: "t_admin_to_x", message_id: "m563", from: X.alias, to: adminName, status: "replied" });
|
|
470
|
+
pushNetworkObserverEvent(NET, { type: "new_task", task_id: "t563_live_zx", from: Z.alias, to: X.alias, status: "delivered" });
|
|
471
|
+
pushNetworkObserverEvent(NET, { type: "new_reply", task_id: "t563_live_xz", message_id: "m563z", from: X.alias, to: Z.alias, status: "replied" });
|
|
472
|
+
pushNetworkObserverEvent(NET, { type: "new_task", task_id: "t563_live_az", from: adminName, to: Z.alias, status: "delivered" });
|
|
473
|
+
await readFor(400);
|
|
474
|
+
ctrl.abort();
|
|
475
|
+
const events = buf.split("\n").filter(l => l.startsWith("data:")).map(l => JSON.parse(l.slice(5)));
|
|
476
|
+
expect(events.some(e => e.type === "new_task" && e.from === adminName && e.to === X.alias)).toBe(true);
|
|
477
|
+
expect(events.some(e => e.type === "new_reply" && e.from === X.alias && e.to === adminName)).toBe(true);
|
|
478
|
+
expect(events.some(e => e.from === Z.alias || e.to === Z.alias)).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
344
482
|
describe("requirements: agent references alice cannot see are hidden and protected", () => {
|
|
345
483
|
let reqId = "";
|
|
346
484
|
test("a card owned by Z shows agent_owner=null to alice, Z to admin", async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 多用户账号与 Agent 权限 —— slice 3:剩下的面向 Agent 的路径。
|
|
2
2
|
//
|
|
3
3
|
// slice 1(agent-acl-http.test.ts)把默认作用域做成 fail-closed,并对核心路径逐条放行。
|
|
4
|
-
// 这里补:授权后 task_events
|
|
4
|
+
// 这里补:授权后 task_events 跟随授权 Agent 的时间线(#563)、messages 的「只看自己往来」、改名广播不外泄 alias、
|
|
5
5
|
// 排程在建的人被收权后不再代派、用户名与后注册的 Agent alias 撞名、以及
|
|
6
6
|
// rules / skills / files / logs 对受限成员(即使授权了)仍然拒绝 —— 这是一个明确的产品决定:
|
|
7
7
|
// 授权 = 能看见、能对话;不等于能管理这个 Agent。
|
|
@@ -104,14 +104,14 @@ afterAll(() => {
|
|
|
104
104
|
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
describe("granted member: task_events
|
|
108
|
-
test("task_events:
|
|
107
|
+
describe("granted member: task_events follow X's timeline (#563); messages stay own traffic", () => {
|
|
108
|
+
test("task_events: every task on granted X's timeline is visible, including admin's (#563)", async () => {
|
|
109
109
|
const mine = await get(carolToken, `/api/task_events?task_id=t3_carol_x`);
|
|
110
110
|
expect(mine.body.events.length).toBe(1);
|
|
111
111
|
expect((await get(carolToken, `/api/task_events?task_id=t3_x_carol`)).body.events.length).toBe(1);
|
|
112
|
-
expect((await get(carolToken, `/api/task_events?task_id=t3_admin_x`)).body.events).
|
|
112
|
+
expect((await get(carolToken, `/api/task_events?task_id=t3_admin_x`)).body.events.length).toBe(1);
|
|
113
113
|
const all = await get(carolToken, `/api/task_events?network_id=${NET}`);
|
|
114
|
-
expect(all.body.events.map((e: any) => e.task_id).sort()).toEqual(["t3_carol_x", "t3_x_carol"]);
|
|
114
|
+
expect(all.body.events.map((e: any) => e.task_id).sort()).toEqual(["t3_admin_x", "t3_carol_x", "t3_x_carol"]);
|
|
115
115
|
});
|
|
116
116
|
test("messages (alias branch): own inbox rows with X only; admin's never", async () => {
|
|
117
117
|
const r = await get(carolToken, `/api/messages?network_id=${NET}&since=2000-01-01`);
|
package/src/network-scope.ts
CHANGED
|
@@ -177,6 +177,58 @@ export function addOwnTrafficScope(sql: string, params: any[], scope: RestNetwor
|
|
|
177
177
|
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* #563 —— 「不同用户看到同一个节点,消息应该是一样的」。
|
|
182
|
+
*
|
|
183
|
+
* tasks 行(聊天窗口的历史 + 回复都在这张表)的作用域:受限网络里,只要成员**看得见** Agent N
|
|
184
|
+
* (直接授权或经组授权,见 visibleAgents),N 的整条时间线他都看得见 —— 不论是谁发的(包括 owner、
|
|
185
|
+
* 其他成员),也包括授权之前的历史。时间线 = 一端是看得见的 Agent,另一端是:
|
|
186
|
+
* - 另一个看得见的 Agent;或
|
|
187
|
+
* - 不是本网络任何 Agent 的名字(人类用户 / scheduler / hub 这类系统发送方)。
|
|
188
|
+
* 另一端是**看不见**的 Agent 的行一律不放行(不借时间线暴露看不见的节点)。
|
|
189
|
+
* 没有任何授权的成员什么都看不到(与之前一样);能不能**发**仍由 canMessageAgent 管。
|
|
190
|
+
* Agent 主动发给某个人的 user_inbox 私信不在这张表里,不受影响。
|
|
191
|
+
*
|
|
192
|
+
* 与 addOwnTrafficScope 的区别只在受限网络:那里只放行「自己 ↔ 授权 Agent」。inbox(投递队列)
|
|
193
|
+
* 仍走 addOwnTrafficScope。用户名与 Agent alias 撞名的网络照旧整网不放行(fail-closed)。
|
|
194
|
+
*/
|
|
195
|
+
export function addAgentTimelineScope(sql: string, params: any[], scope: RestNetworkScope, cols: Required<Pick<TrafficColumns, "from" | "to" | "fromNodeId" | "toNodeId">> & { network?: string }): string {
|
|
196
|
+
const restriction = scope.agentRestriction;
|
|
197
|
+
if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
|
|
198
|
+
const net = cols.network ?? "network_id";
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
201
|
+
if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
|
|
202
|
+
if (restriction.username) {
|
|
203
|
+
for (const networkId of restriction.networkIds) {
|
|
204
|
+
if (usernameIsAgentAlias(networkId, restriction.username)) continue;
|
|
205
|
+
const visible = visibleAgents(restriction.userId, networkId);
|
|
206
|
+
// 先判空再推参数:推了又不用的绑定参数会让 PG 报错(SQLite 会忽略)。
|
|
207
|
+
if (visible.aliases.length === 0 && visible.nodeIds.length === 0) continue;
|
|
208
|
+
const aliasIn = visible.aliases.length ? placeholders(params, visible.aliases) : null;
|
|
209
|
+
const nodeIn = visible.nodeIds.length ? placeholders(params, visible.nodeIds) : null;
|
|
210
|
+
const netP = placeholders(params, [networkId]);
|
|
211
|
+
const isVisible = (aliasCol: string, nodeCol: string): string => {
|
|
212
|
+
const conds: string[] = [];
|
|
213
|
+
if (aliasIn) conds.push(`${aliasCol} IN (${aliasIn})`);
|
|
214
|
+
if (nodeIn) conds.push(`${nodeCol} IN (${nodeIn})`);
|
|
215
|
+
return `(${conds.join(" OR ")})`;
|
|
216
|
+
};
|
|
217
|
+
// 「不是本网络任何 Agent」:没有 node_id,且 alias 不在本网络的 sessions / nodes 里。
|
|
218
|
+
const notAnAgent = (aliasCol: string, nodeCol: string): string =>
|
|
219
|
+
`(${nodeCol} IS NULL`
|
|
220
|
+
+ ` AND ${aliasCol} NOT IN (SELECT alias FROM sessions WHERE network_id = ${netP} AND alias IS NOT NULL)`
|
|
221
|
+
+ ` AND ${aliasCol} NOT IN (SELECT alias FROM nodes WHERE network_id = ${netP} AND alias IS NOT NULL))`;
|
|
222
|
+
const toV = isVisible(cols.to, cols.toNodeId);
|
|
223
|
+
const fromV = isVisible(cols.from, cols.fromNodeId);
|
|
224
|
+
const otherFrom = `(${fromV} OR ${notAnAgent(cols.from, cols.fromNodeId)})`;
|
|
225
|
+
const otherTo = `(${toV} OR ${notAnAgent(cols.to, cols.toNodeId)})`;
|
|
226
|
+
parts.push(`(${net} = ${netP} AND ((${toV} AND ${otherFrom}) OR (${fromV} AND ${otherTo})))`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
230
|
+
}
|
|
231
|
+
|
|
180
232
|
export function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
181
233
|
if (scope.networkId) return scope.networkId;
|
|
182
234
|
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
@@ -22,6 +22,7 @@ delete process.env.COMMHUB_DUE_REMINDER_NETWORKS;
|
|
|
22
22
|
delete process.env.COMMHUB_DUE_REMINDERS;
|
|
23
23
|
delete process.env.COMMHUB_DUE_REMINDERS_NETWORKS;
|
|
24
24
|
delete process.env.COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS;
|
|
25
|
+
delete process.env.COMMHUB_DUE_REMINDERS_OWNERS;
|
|
25
26
|
// 节点提醒默认关;这个文件的大部分用例要看节点那一路,先打开(「默认关」单独一个用例)。
|
|
26
27
|
process.env.COMMHUB_DUE_REMINDER_NODES = "1";
|
|
27
28
|
const PW = "DueRemindPassw0rd!x";
|
|
@@ -340,7 +341,7 @@ describe("上线护栏", () => {
|
|
|
340
341
|
});
|
|
341
342
|
|
|
342
343
|
// ── #524 按网络开关 ────────────────────────────────────
|
|
343
|
-
const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS"];
|
|
344
|
+
const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"];
|
|
344
345
|
async function withEnv<T>(vars: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
|
|
345
346
|
const saved = Object.fromEntries(SCOPE_VARS.map((k) => [k, process.env[k]]));
|
|
346
347
|
for (const k of SCOPE_VARS) delete process.env[k];
|
|
@@ -463,6 +464,107 @@ describe("上线护栏", () => {
|
|
|
463
464
|
"[due-reminders] scope: all networks except net_placeholder_x",
|
|
464
465
|
]);
|
|
465
466
|
});
|
|
467
|
+
|
|
468
|
+
// ── #524 按负责人开关(两个团队共用一个网络,网络名单分不开)────────
|
|
469
|
+
test("#524 负责人名单:范围解析 / 空白 = 没设 / 只在用到时绑参数", async () => {
|
|
470
|
+
const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
|
|
471
|
+
// =0 + 负责人名单:与网络白名单同一语义 —— 单独就能打开,范围 = 所有网络里名单里的人负责的卡
|
|
472
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " u_a , u_b,u_a, " })).toEqual({ mode: "all", exclude: [], owners: ["u_a", "u_b"] });
|
|
473
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: [], owners: ["u_a"] });
|
|
474
|
+
// 与网络白名单 / 黑名单叠加(且)
|
|
475
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a"] });
|
|
476
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: ["net_x"], owners: ["u_a"] });
|
|
477
|
+
// 空 / 空白 / 只有逗号 = 没设:=0 时仍然关;不设 =0 时与原来完全一样(没有 owners 键)
|
|
478
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "" })).toEqual({ mode: "off" });
|
|
479
|
+
expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " , ," })).toEqual({ mode: "off" });
|
|
480
|
+
expect(await S({ COMMHUB_DUE_REMINDERS_OWNERS: " " })).toStrictEqual({ mode: "all", exclude: [] });
|
|
481
|
+
// SQL:没名单不 push;有名单精确匹配存储形状,占位号接在已有参数后面
|
|
482
|
+
const p0: unknown[] = ["lo", "hi"];
|
|
483
|
+
expect(due.dueOwnerSql({ mode: "all", exclude: [] }, p0)).toBe("");
|
|
484
|
+
expect(due.dueOwnerSql({ mode: "off" }, p0)).toBe("");
|
|
485
|
+
expect(p0).toEqual(["lo", "hi"]);
|
|
486
|
+
const p1: unknown[] = ["lo", "hi", "net_x"];
|
|
487
|
+
expect(due.dueOwnerSql({ mode: "all", exclude: ["net_x"], owners: ["u_a", "u_b"] }, p1)).toBe(" AND owner_json IN (?4, ?5)");
|
|
488
|
+
expect(p1).toEqual(["lo", "hi", "net_x", JSON.stringify(userRef("u_a")), JSON.stringify(userRef("u_b"))]);
|
|
489
|
+
// 日志
|
|
490
|
+
expect(due.describeDueReminderScope({ mode: "all", exclude: [], owners: ["u_a"] })).toBe("all networks; only cards owned by u_a");
|
|
491
|
+
expect(due.describeDueReminderScope({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a", "u_b"] })).toBe("only networks net_a; only cards owned by u_a,u_b");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("#524 COMMHUB_DUE_REMINDERS=0 + 负责人名单:同一网络里只有名单里的人负责的卡发(人 + 负责 Agent);别人负责的一条不发", async () => {
|
|
495
|
+
const listed = await card("dr-524-名单内负责人", "2026-10-11");
|
|
496
|
+
const unlisted = await card("dr-524-名单外负责人", "2026-10-11", { owner: userRef(pia.id), participants: [userRef(pat.id)] });
|
|
497
|
+
const noOwner = await card("dr-524-没有负责人", "2026-10-11", { owner: null, participants: [userRef(owen.id)] });
|
|
498
|
+
const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: ` ${owen.id} ,` }, () => due.runDueReminders({ now: T0 }));
|
|
499
|
+
// 名单里的人负责的卡:恰好一档,收件人 = 负责人 + 参与人,负责 Agent 也收到一条
|
|
500
|
+
expect(sentFor(out, listed)).toEqual(["due_soon"]);
|
|
501
|
+
expect(out.find((x) => x.requirement_id === listed)?.users.sort()).toEqual([owen.id, pat.id, pia.id].sort());
|
|
502
|
+
expect(out.find((x) => x.requirement_id === listed)?.node).toBe(ALIAS);
|
|
503
|
+
const n = userNotices(listed);
|
|
504
|
+
expect(Object.keys(n).sort()).toEqual([owen.id, pat.id, pia.id].sort());
|
|
505
|
+
for (const id of [owen.id, pat.id, pia.id]) expect(n[id].map((x) => x.reminder)).toEqual(["due_soon"]);
|
|
506
|
+
expect(nodeMessages(listed)).toHaveLength(1);
|
|
507
|
+
// 名单外的人负责的卡 / 没有负责人的卡:什么都不发,连去重行都不写(以后把人加进来,照样按时发)
|
|
508
|
+
for (const id of [unlisted, noOwner]) {
|
|
509
|
+
expect(sentFor(out, id)).toEqual([]);
|
|
510
|
+
expect(userNotices(id)).toEqual({});
|
|
511
|
+
expect(nodeMessages(id)).toEqual([]);
|
|
512
|
+
expect(rowCount(id)).toBe(0);
|
|
513
|
+
}
|
|
514
|
+
// 返回值里也只有名单里的人负责的卡
|
|
515
|
+
for (const x of out) {
|
|
516
|
+
const row = db.get("SELECT owner_json FROM requirements WHERE requirement_id = ?1", x.requirement_id);
|
|
517
|
+
expect(JSON.parse(row.owner_json)).toEqual(userRef(owen.id));
|
|
518
|
+
}
|
|
519
|
+
// 同一时刻再跑:不重发
|
|
520
|
+
expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 })), listed)).toEqual([]);
|
|
521
|
+
// 与网络白名单是「且」:人在名单里、网络不在 → 不发
|
|
522
|
+
const other = mkNet("own_and");
|
|
523
|
+
const c = await card("dr-524-人对网络不对", "2026-10-11");
|
|
524
|
+
const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: other.net, COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 }));
|
|
525
|
+
expect(sentFor(out2, c)).toEqual([]);
|
|
526
|
+
expect(rowCount(c)).toBe(0);
|
|
527
|
+
// 名单撤掉(=1、不设名单)→ 名单外负责人的卡照常发:证明上面是名单挡的,不是卡本身不该发
|
|
528
|
+
expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "1" }, () => due.runDueReminders({ now: T0 })), unlisted)).toEqual(["due_soon"]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("#524 负责人名单不补发:网络早就开着,人后加进名单 —— 他加进来之前就逾期的卡不发,之后才逾期的照发", async () => {
|
|
532
|
+
const f = mkNet("own_late");
|
|
533
|
+
// 网络基线很早(T0 - 30 天),人还不在任何名单里
|
|
534
|
+
await withEnv({ COMMHUB_DUE_REMINDERS_NETWORKS: f.net }, () => due.runDueReminders({ now: T0 - 30 * DAY }));
|
|
535
|
+
expect(baselineCount(f.net)).toBe(1);
|
|
536
|
+
const old = await cardIn(f, "dr-524-进名单前逾期", "2026-10-07");
|
|
537
|
+
const today = await cardIn(f, "dr-524-进名单当天到期", "2026-10-10");
|
|
538
|
+
const env = { COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: f.id };
|
|
539
|
+
const first = await withEnv(env, () => due.runDueReminders({ now: T0 }));
|
|
540
|
+
// 网络基线比逾期早 —— 只看网络基线就会补发;负责人基线挡住
|
|
541
|
+
expect(sentFor(first, old)).toEqual([]);
|
|
542
|
+
expect(sentFor(first, today)).toEqual(["due_today"]);
|
|
543
|
+
const ob = db.all("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net);
|
|
544
|
+
expect(ob).toEqual([{ network_id: f.net, due_on: f.id, sent_at: new Date(T0).toISOString() }]);
|
|
545
|
+
const next = await withEnv(env, () => due.runDueReminders({ now: T0 + DAY }));
|
|
546
|
+
expect(sentFor(next, old)).toEqual([]);
|
|
547
|
+
expect(sentFor(next, today)).toEqual(["overdue"]);
|
|
548
|
+
// 负责人基线不被保留期清掉
|
|
549
|
+
await withEnv(env, () => due.runDueReminders({ now: T0 + 90 * DAY }));
|
|
550
|
+
expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net).n).toBe(1);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("#524 启动:=0 + 负责人名单起定时器,日志只有 id", async () => {
|
|
554
|
+
const logs: string[] = [];
|
|
555
|
+
const g = globalThis as any;
|
|
556
|
+
const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
|
|
557
|
+
let timers = 0;
|
|
558
|
+
console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
|
|
559
|
+
g.setTimeout = () => { timers++; return { unref() {} }; };
|
|
560
|
+
g.setInterval = () => { timers++; return { fake: true }; };
|
|
561
|
+
try {
|
|
562
|
+
const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "u_placeholder_a" }, () => due.startDueReminderTimer());
|
|
563
|
+
expect(on).toEqual({ fake: true } as any);
|
|
564
|
+
expect(timers).toBe(2);
|
|
565
|
+
} finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
|
|
566
|
+
expect(logs.filter((l) => l.startsWith("[due-reminders] scope:"))).toEqual(["[due-reminders] scope: all networks; only cards owned by u_placeholder_a"]);
|
|
567
|
+
});
|
|
466
568
|
});
|
|
467
569
|
|
|
468
570
|
describe("#494 列表筛选", () => {
|
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
// #524 按网络开关(一个 Hub 上多个团队的网络,只想给自己的网络开):
|
|
25
25
|
// COMMHUB_DUE_REMINDERS_NETWORKS=<id,id,…> 白名单。COMMHUB_DUE_REMINDERS=0 时也生效:只给这些网络发。
|
|
26
26
|
// COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS=<id,id,…> 黑名单。功能开着时这些网络不发(白名单里也排除)。
|
|
27
|
-
//
|
|
27
|
+
// #524 按人开关(多个团队共用同一个网络,网络名单分不开时):
|
|
28
|
+
// COMMHUB_DUE_REMINDERS_OWNERS=<user id,…> 负责人白名单。设了就只扫负责人(owner)在名单里的卡;
|
|
29
|
+
// 这张卡的人提醒(负责人 + 参与人)和负责 Agent 消息照常发,别人负责的卡一条都不发。与网络白名单 / 黑名单是「且」。
|
|
30
|
+
// 和 COMMHUB_DUE_REMINDERS_NETWORKS 一样,COMMHUB_DUE_REMINDERS=0 时也生效(只给名单里的人负责的卡打开)。
|
|
31
|
+
// 开通不补发按「网络 × 负责人」各记一行基线(kind='baseline_owner',due_on = user id):以后把人加进名单,
|
|
32
|
+
// 从加进来那一刻算起,不会补发他过去几天的逾期卡。
|
|
33
|
+
// 这些新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
|
|
28
34
|
// 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
|
|
29
35
|
// COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
|
|
30
36
|
// 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
|
|
@@ -99,23 +105,39 @@ export function reminderNetworks(): string[] | null {
|
|
|
99
105
|
*/
|
|
100
106
|
export type DueReminderScope =
|
|
101
107
|
| { mode: "off" }
|
|
102
|
-
| { mode: "all"; exclude: string[] }
|
|
103
|
-
| { mode: "only"; include: string[]; exclude: string[] };
|
|
108
|
+
| { mode: "all"; exclude: string[]; owners?: string[] }
|
|
109
|
+
| { mode: "only"; include: string[]; exclude: string[]; owners?: string[] };
|
|
104
110
|
|
|
111
|
+
/**
|
|
112
|
+
* owners(COMMHUB_DUE_REMINDERS_OWNERS)只在设了时出现:只扫负责人在名单里的卡(与网络条件是「且」)。
|
|
113
|
+
* 与 COMMHUB_DUE_REMINDERS_NETWORKS 同一语义:COMMHUB_DUE_REMINDERS=0 时也能单独把功能打开(只对名单里的人)。
|
|
114
|
+
*/
|
|
105
115
|
export function dueReminderScope(): DueReminderScope {
|
|
106
116
|
const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
|
|
107
117
|
const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
|
|
108
118
|
const exclude = envIdList("COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS") ?? [];
|
|
109
|
-
|
|
110
|
-
if (!allow && !
|
|
119
|
+
const owners = envIdList("COMMHUB_DUE_REMINDERS_OWNERS");
|
|
120
|
+
if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow && !owners) return { mode: "off" };
|
|
121
|
+
const withOwners = owners ? { owners } : {};
|
|
122
|
+
if (!allow && !legacy) return { mode: "all", exclude, ...withOwners };
|
|
111
123
|
const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
|
|
112
|
-
return { mode: "only", include, exclude };
|
|
124
|
+
return { mode: "only", include, exclude, ...withOwners };
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
|
|
116
128
|
if (scope.mode === "off") return "off";
|
|
117
|
-
|
|
118
|
-
return scope.
|
|
129
|
+
const who = scope.owners ? `; only cards owned by ${scope.owners.join(",")}` : "";
|
|
130
|
+
if (scope.mode === "all") return (scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks") + who;
|
|
131
|
+
return scope.include.length ? `only networks ${scope.include.join(",")}${who}` : "no networks (allowlist empty after exclude)";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 负责人条件(只拼在 requirements 的候选 SELECT 上;networks 表没有负责人)。没设名单 → "",不 push 任何参数。
|
|
136
|
+
* 精确匹配存储形状 {"kind":"user","id":…}(与列表 ?owner=user:<id> 筛选同一写法):对不上就不发,宁可少发。
|
|
137
|
+
*/
|
|
138
|
+
export function dueOwnerSql(scope: DueReminderScope, params: unknown[]): string {
|
|
139
|
+
if (scope.mode === "off" || !scope.owners?.length) return "";
|
|
140
|
+
return ` AND owner_json IN (${scope.owners.map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`;
|
|
119
141
|
}
|
|
120
142
|
|
|
121
143
|
/**
|
|
@@ -133,6 +155,7 @@ export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string
|
|
|
133
155
|
}
|
|
134
156
|
export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
|
|
135
157
|
export const BASELINE_KIND = "baseline";
|
|
158
|
+
export const OWNER_BASELINE_KIND = "baseline_owner";
|
|
136
159
|
|
|
137
160
|
export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
|
|
138
161
|
|
|
@@ -280,6 +303,23 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
280
303
|
ON CONFLICT DO NOTHING`,
|
|
281
304
|
bp,
|
|
282
305
|
);
|
|
306
|
+
// 负责人名单:每个(范围内网络 × 名单里的人)第一次被扫到的那一刻。due_on 列存 user id。
|
|
307
|
+
if (scope.mode !== "off") for (const owner of scope.owners ?? []) {
|
|
308
|
+
const op: unknown[] = [OWNER_BASELINE_KIND, sentAt, owner];
|
|
309
|
+
db.run(
|
|
310
|
+
`INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
|
|
311
|
+
SELECT '__baseline__:' || network_id, ?1, ?3, '', network_id, ?2 FROM networks WHERE 1=1${dueScopeSql(scope, op) ?? ""}
|
|
312
|
+
ON CONFLICT DO NOTHING`,
|
|
313
|
+
op,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const ownerBaselines = new Map<string, number>();
|
|
318
|
+
if (scope.mode !== "off" && scope.owners?.length) {
|
|
319
|
+
for (const b of db.all<{ network_id: string; due_on: string; sent_at: string }>("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = ?1", OWNER_BASELINE_KIND)) {
|
|
320
|
+
const t = parseHubTimestamp(b.sent_at);
|
|
321
|
+
if (t !== null) ownerBaselines.set(`${b.network_id}\u0000${b.due_on}`, t);
|
|
322
|
+
}
|
|
283
323
|
}
|
|
284
324
|
const baselines = new Map(
|
|
285
325
|
db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
|
|
@@ -288,10 +328,11 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
288
328
|
);
|
|
289
329
|
const params: unknown[] = [lo, hi];
|
|
290
330
|
const netFilter = dueScopeSql(scope, params) ?? "";
|
|
331
|
+
const ownerFilter = dueOwnerSql(scope, params);
|
|
291
332
|
const rows = db.all<DueRow>(
|
|
292
333
|
`SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
|
|
293
334
|
FROM requirements
|
|
294
|
-
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}`,
|
|
335
|
+
WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
|
|
295
336
|
...params,
|
|
296
337
|
);
|
|
297
338
|
const out: DueReminderSent[] = [];
|
|
@@ -300,7 +341,13 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
300
341
|
if (!r) continue;
|
|
301
342
|
if (r.kind === "overdue") {
|
|
302
343
|
// 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
|
|
303
|
-
|
|
344
|
+
let base = baselines.get(row.network_id) ?? nowMs;
|
|
345
|
+
// 负责人名单:再取「这个人进名单」的基线,两者取晚的(把人加进名单也不补发)。
|
|
346
|
+
if (scope.mode !== "off" && scope.owners?.length) {
|
|
347
|
+
const owner = userIdOf(parse(row.owner_json));
|
|
348
|
+
const ownerBase = owner ? ownerBaselines.get(`${row.network_id}\u0000${owner}`) : undefined;
|
|
349
|
+
base = Math.max(base, ownerBase ?? nowMs);
|
|
350
|
+
}
|
|
304
351
|
if (overdueSinceMs(row.due_on, tz) < base) continue;
|
|
305
352
|
}
|
|
306
353
|
try {
|
|
@@ -316,7 +363,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
|
|
|
316
363
|
}
|
|
317
364
|
}
|
|
318
365
|
// 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
|
|
319
|
-
try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind
|
|
366
|
+
try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind NOT IN (?2, ?3)", [new Date(nowMs - RETENTION_DAYS * DAY_MS).toISOString(), BASELINE_KIND, OWNER_BASELINE_KIND]); } catch {}
|
|
320
367
|
return out;
|
|
321
368
|
}
|
|
322
369
|
|
|
@@ -354,7 +401,7 @@ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: s
|
|
|
354
401
|
}
|
|
355
402
|
|
|
356
403
|
/**
|
|
357
|
-
* startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS,见 dueReminderScope);
|
|
404
|
+
* startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS / _OWNERS,见 dueReminderScope);
|
|
358
405
|
* bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
|
|
359
406
|
*/
|
|
360
407
|
export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
|
package/src/restricted-files.ts
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
// 也不能把自己看不见的 file_id 塞进发给授权 Agent 的任务(那等于借 Agent 的网络令牌去读)。
|
|
4
4
|
import { existsSync, readFileSync } from "fs";
|
|
5
5
|
import { db } from "./db.js";
|
|
6
|
-
import { addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
|
|
6
|
+
import { addAgentTimelineScope, addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
|
|
7
7
|
import { FILE_ID_REGEX, indexEntryPath, validateIndexEntry } from "./uploads.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* 受限成员能不能看见这个文件:只认**对方(授权 Agent / 给他发私信的人)放进来**的附件 ——
|
|
11
11
|
* user_inbox 里发给自己的私信附件;
|
|
12
|
-
* 授权 Agent
|
|
13
|
-
*
|
|
12
|
+
* 授权 Agent 时间线上(#563,与 /api/tasks 同一判据 addAgentTimelineScope)**别人**发出的任务的
|
|
13
|
+
* attachments —— 时间线上看得见那条消息,就得看得见它带的图;发送方是受限成员时发送路径已校验过
|
|
14
|
+
* 他看得见这些文件,不受限的发送方本来就看得见全网文件;
|
|
15
|
+
* 时间线上任务的 reply_attachments(Agent 回复写进的);
|
|
16
|
+
* 授权 Agent 发给自己的 inbox 行的 attachments。
|
|
14
17
|
* 自己随任务带出去的 attachments **不算**:否则把任意 file_id 塞进自己的任务就能「解锁」别人的文件。
|
|
15
18
|
*/
|
|
16
19
|
export function restrictedMemberSeesFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
@@ -32,10 +35,10 @@ export function restrictedMemberSeesFile(userId: string, username: string, netwo
|
|
|
32
35
|
}
|
|
33
36
|
const scope: RestNetworkScope = { networkId, networkIds: null, agentRestriction: { userId, username, networkIds: [networkId] } };
|
|
34
37
|
const taskParams: unknown[] = [needle];
|
|
35
|
-
const taskSql =
|
|
38
|
+
const taskSql = addAgentTimelineScope("SELECT from_name, to_name, meta_json FROM tasks WHERE meta_json LIKE ?1 ESCAPE '\\'", taskParams, scope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
36
39
|
for (const row of db.all<{ from_name: string; to_name: string; meta_json: string | null }>(taskSql + " LIMIT 50", ...taskParams)) {
|
|
37
|
-
if (row.
|
|
38
|
-
if (
|
|
40
|
+
if (row.from_name !== username && listed(row.meta_json, "attachments")) return true;
|
|
41
|
+
if (listed(row.meta_json, "reply_attachments")) return true;
|
|
39
42
|
}
|
|
40
43
|
const inboxParams: unknown[] = [needle];
|
|
41
44
|
const inboxSql = addOwnTrafficScope("SELECT session_name, meta_json FROM inbox WHERE meta_json LIKE ?1 ESCAPE '\\'", inboxParams, scope, { from: "from_session", to: "session_name" });
|
package/src/server.ts
CHANGED
|
@@ -25,12 +25,12 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
25
25
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
26
26
|
import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
|
|
27
27
|
import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
28
|
-
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
28
|
+
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
29
29
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
30
30
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
31
31
|
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
32
32
|
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
33
|
-
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
33
|
+
import { canMessageAgent, isAgentRestricted, visibleAgents, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
34
34
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
|
|
35
35
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
36
36
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
@@ -1097,13 +1097,38 @@ return Bun.serve({
|
|
|
1097
1097
|
}
|
|
1098
1098
|
const streamDenied = restNodeDenied(req, authCtx, "SSE /events/network/:id", (id) => streamVerdict(id, "network"));
|
|
1099
1099
|
if (streamDenied) return streamDenied;
|
|
1100
|
-
// 多用户 Agent
|
|
1101
|
-
//
|
|
1100
|
+
// 多用户 Agent 权限:受限成员收到自己是一端的路由事件,以及授权 Agent 时间线上的事件
|
|
1101
|
+
// (#563:与 /api/tasks 的 addAgentTimelineScope 同一判据 —— 一端是看得见的 Agent,另一端是
|
|
1102
|
+
// 看得见的 Agent 或不是本网络任何 Agent;另一端是看不见的 Agent 的事件不推)。
|
|
1102
1103
|
if (isAgentRestricted(authCtx.userId, observedNetId)) {
|
|
1103
1104
|
const me = authCtx.username;
|
|
1105
|
+
const userId = authCtx.userId;
|
|
1106
|
+
const collides = usernameIsAgentAlias(observedNetId, me);
|
|
1107
|
+
// 授权与 Agent 名单每条事件都现查太贵;缓存 5 秒(授权撤销最多晚 5 秒对推送生效)。
|
|
1108
|
+
let cache: { at: number; visible: Set<string>; agents: Set<string> } | null = null;
|
|
1109
|
+
const sets = () => {
|
|
1110
|
+
if (!cache || Date.now() - cache.at > 5_000) {
|
|
1111
|
+
const visible = new Set(visibleAgents(userId, observedNetId).aliases);
|
|
1112
|
+
const agents = new Set(db.all<{ alias: string }>(
|
|
1113
|
+
"SELECT alias FROM sessions WHERE network_id = ?1 AND alias IS NOT NULL UNION SELECT alias FROM nodes WHERE network_id = ?1 AND alias IS NOT NULL",
|
|
1114
|
+
observedNetId,
|
|
1115
|
+
).map((r) => r.alias));
|
|
1116
|
+
cache = { at: Date.now(), visible, agents };
|
|
1117
|
+
}
|
|
1118
|
+
return cache;
|
|
1119
|
+
};
|
|
1104
1120
|
return createNetworkObserverStream(observedNetId, {
|
|
1105
1121
|
subscriberUserId: authCtx.userId,
|
|
1106
|
-
eventFilter: (event) =>
|
|
1122
|
+
eventFilter: (event) => {
|
|
1123
|
+
if (event.from === me || event.to === me) return true;
|
|
1124
|
+
if (collides) return false;
|
|
1125
|
+
const { visible, agents } = sets();
|
|
1126
|
+
const from = typeof event.from === "string" ? event.from : null;
|
|
1127
|
+
const to = typeof event.to === "string" ? event.to : null;
|
|
1128
|
+
const vis = (a: string | null) => !!a && visible.has(a);
|
|
1129
|
+
const otherOk = (a: string | null) => vis(a) || (!!a && !agents.has(a));
|
|
1130
|
+
return (vis(to) && otherOk(from)) || (vis(from) && otherOk(to));
|
|
1131
|
+
},
|
|
1107
1132
|
});
|
|
1108
1133
|
}
|
|
1109
1134
|
return createNetworkObserverStream(observedNetId, { subscriberUserId: authCtx.userId });
|
|
@@ -2225,7 +2250,7 @@ return Bun.serve({
|
|
|
2225
2250
|
if (!viewerRole && !isHubAdminCredential(resolved)) {
|
|
2226
2251
|
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
2227
2252
|
}
|
|
2228
|
-
// Get network stats —— 受限成员只数得到授权给他的 Agent
|
|
2253
|
+
// Get network stats —— 受限成员只数得到授权给他的 Agent 的时间线(#563)。
|
|
2229
2254
|
const detailScope: RestNetworkScope = resolved.user.role === "admin" || resolved.networkId
|
|
2230
2255
|
? { networkId, networkIds: null }
|
|
2231
2256
|
: resolveRestNetworkScope(networkId, { userId: resolved.user.user_id, networkId: null, username: resolved.user.username }, false);
|
|
@@ -2234,7 +2259,7 @@ return Bun.serve({
|
|
|
2234
2259
|
const sessionParams: unknown[] = [];
|
|
2235
2260
|
const sessionCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM sessions WHERE 1=1", sessionParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...sessionParams);
|
|
2236
2261
|
const taskParams: unknown[] = [];
|
|
2237
|
-
const taskStats = db.all<any>(
|
|
2262
|
+
const taskStats = db.all<any>(addAgentTimelineScope("SELECT status, COUNT(*) as count FROM tasks WHERE 1=1", taskParams, detailScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + " GROUP BY status", ...taskParams);
|
|
2238
2263
|
return withCors(req, Response.json({
|
|
2239
2264
|
ok: true, network,
|
|
2240
2265
|
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
@@ -4061,9 +4086,9 @@ return Bun.serve({
|
|
|
4061
4086
|
let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
|
|
4062
4087
|
const params: any[] = [];
|
|
4063
4088
|
if (restScope.agentRestriction) {
|
|
4064
|
-
//
|
|
4089
|
+
// 受限成员:只看得到授权 Agent 时间线上那些任务的事件(#563,与 /api/tasks 同一判据)。
|
|
4065
4090
|
sql += " AND task_id IN (SELECT task_id FROM tasks WHERE 1=1";
|
|
4066
|
-
sql =
|
|
4091
|
+
sql = addAgentTimelineScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + ")";
|
|
4067
4092
|
} else {
|
|
4068
4093
|
sql = addNetworkScope(sql, params, restScope);
|
|
4069
4094
|
}
|
|
@@ -4656,7 +4681,7 @@ return Bun.serve({
|
|
|
4656
4681
|
const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
|
|
4657
4682
|
const params: any[] = [taskId];
|
|
4658
4683
|
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
|
|
4659
|
-
sql =
|
|
4684
|
+
sql = addAgentTimelineScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4660
4685
|
sql += " LIMIT 1";
|
|
4661
4686
|
const task = db.get(sql, ...params);
|
|
4662
4687
|
if (!task) {
|
|
@@ -4745,7 +4770,7 @@ return Bun.serve({
|
|
|
4745
4770
|
|
|
4746
4771
|
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
|
|
4747
4772
|
const params: any[] = [];
|
|
4748
|
-
sql =
|
|
4773
|
+
sql = addAgentTimelineScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4749
4774
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
4750
4775
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
4751
4776
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -4768,7 +4793,7 @@ return Bun.serve({
|
|
|
4768
4793
|
}
|
|
4769
4794
|
const statsParams: any[] = [];
|
|
4770
4795
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
4771
|
-
statsSql =
|
|
4796
|
+
statsSql = addAgentTimelineScope(statsSql, statsParams, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4772
4797
|
statsSql += " GROUP BY status";
|
|
4773
4798
|
const stats = db.all<any>(statsSql, ...statsParams);
|
|
4774
4799
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
package/src/tools.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js"
|
|
|
12
12
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
13
13
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
14
14
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
15
|
-
import { addAgentNetworkScope,
|
|
15
|
+
import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
16
16
|
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
17
17
|
import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
|
|
18
18
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
@@ -2530,7 +2530,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2530
2530
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
2531
2531
|
const params: any[] = [task_id];
|
|
2532
2532
|
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
2533
|
-
sql =
|
|
2533
|
+
sql = addAgentTimelineScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2534
2534
|
const task = db.get<any>(sql, ...params);
|
|
2535
2535
|
return {
|
|
2536
2536
|
content: [{
|
|
@@ -2599,7 +2599,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2599
2599
|
}
|
|
2600
2600
|
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
|
|
2601
2601
|
const params: any[] = [];
|
|
2602
|
-
sql =
|
|
2602
|
+
sql = addAgentTimelineScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2603
2603
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
2604
2604
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
2605
2605
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
@@ -2615,7 +2615,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2615
2615
|
// Stats
|
|
2616
2616
|
const statsParams: any[] = [];
|
|
2617
2617
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
2618
|
-
statsSql =
|
|
2618
|
+
statsSql = addAgentTimelineScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
2619
2619
|
statsSql += " GROUP BY status";
|
|
2620
2620
|
const stats = db.all(statsSql, ...statsParams);
|
|
2621
2621
|
|