@sleep2agi/commhub-server 0.9.0-preview.103 → 0.9.0-preview.105

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.103",
3
+ "version": "0.9.0-preview.105",
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 her own exchange with X, not admin's", async () => {
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
- expect(ids).toEqual(["X answers alice", "hello X", "mcp hello X"].sort());
328
- expect((await get(aliceToken, `/api/tasks/t_admin_to_x`)).status).toBe(404);
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)).not.toContain("admin secret for X");
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 / messages 的「只看自己往来」、改名广播不外泄 alias、
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 and messages are limited to own traffic", () => {
108
- test("task_events: own exchange with X visible, admin's task to X not", async () => {
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).toEqual([]);
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`);
@@ -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];
@@ -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 发给自己的任务 / inbox 行的 attachments;
13
- * 自己发给授权 Agent 的任务上,Agent 回复写进的 reply_attachments。
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 = addOwnTrafficScope("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" });
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.to_name === username && listed(row.meta_json, "attachments")) return true;
38
- if (row.from_name === username && listed(row.meta_json, "reply_attachments")) return true;
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
- // 看不到网络里别人和各个 Agent 之间的流量。
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) => event.from === me || event.to === me,
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>(addOwnTrafficScope("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);
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
- // 受限成员:只看得到自己与授权 Agent 往来的那些任务的事件。
4089
+ // 受限成员:只看得到授权 Agent 时间线上那些任务的事件(#563,与 /api/tasks 同一判据)。
4065
4090
  sql += " AND task_id IN (SELECT task_id FROM tasks WHERE 1=1";
4066
- sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + ")";
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 = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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 = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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 = addOwnTrafficScope(statsSql, statsParams, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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 }));
@@ -435,6 +435,34 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
435
435
  expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A })).ok).toBe(true);
436
436
  });
437
437
 
438
+ // #571 — 手工起的节点(id `n_…`)没有任何 daemon 链接。改前:authority==null 时
439
+ // 放行调用方点名的任意同网 daemon → 门铃发过去,那台 daemon 本地无记录,按 alias
440
+ // 全机 pgrep+SIGTERM,误杀同 alias 的别的 agent-node。改后:不论传没传 daemon 一律拒。
441
+ test("#571: stop/delete of a hand-started node refuse even with an explicit same-network daemon", async () => {
442
+ setupAlphaNetwork();
443
+ const HAND_ID = "n_571abcde";
444
+ const HAND_ALIAS = "sd-hand-571";
445
+ seedChild(NET_A, HAND_ID, HAND_ALIAS, USER_A_ID, "tok_sd_hand_571");
446
+ const u = buildHandlers(USER_A_ID);
447
+ const s = await call(u.stop_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A });
448
+ expect(s.ok).toBe(false);
449
+ expect(s.error).toBe("not_daemon_managed");
450
+ expect(String(s.message)).toContain("anet node stop");
451
+ const d = await call(u.delete_node, { child_node_id: HAND_ID, daemon_node_id: DAEMON_A_ID, confirm_alias: HAND_ALIAS, network_id: NET_A });
452
+ expect(d.error).toBe("not_daemon_managed");
453
+ // 没有任何副作用:状态没动、没写 stop 请求行
454
+ expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
455
+ const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
456
+ expect(Number(rows?.n ?? 0)).toBe(0);
457
+ // 回归钉:daemon 建的 child(有 create 记录)照常可停
458
+ db.run(
459
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
460
+ VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
461
+ [`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
462
+ );
463
+ expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
464
+ });
465
+
438
466
  // #1448 finding-5 — delete 完成时按派发时定住的 token_id 精确撤 ntok,而非按
439
467
  // name='node:<alias>' 广撤。改前:同 alias 在 delete 未收敛窗口内被重建、拿到同名新
440
468
  // token,ack 会把新 token 一并误撤(下面 REBUILT_TOK 断言 not revoked 会红)。
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, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
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 = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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 = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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 = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
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
 
@@ -4698,7 +4698,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4698
4698
  ok: false, error: "not_daemon_managed",
4699
4699
  message:
4700
4700
  `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4701
- `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4701
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it ` +
4702
+ `(naming a daemon_node_id does not change that). ` +
4702
4703
  `Run \`anet node stop <alias>\` on that machine instead. ` +
4703
4704
  `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4704
4705
  };
@@ -4826,6 +4827,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4826
4827
  // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
4827
4828
  // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
4828
4829
  const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
4830
+ // #571 — 手工起的节点(id 不以 `node_` 开头)**没有**任何 daemon 链接。此前
4831
+ // authority==null 时放行调用方点名的任意同网 daemon → 门铃发给那台 daemon,
4832
+ // 它本地没这个 child 的记录,会走「按 alias 全机 pgrep + SIGTERM」的收敛分支,
4833
+ // 杀掉那台机器上所有同 alias 的 agent-node(不分工作目录/HOME/网络)。
4834
+ // 对 hand-started 节点这条路在概念上就不成立(见 explainUnresolvableDaemon ①),
4835
+ // 所以不管调用方传没传 daemon_node_id 一律拒。`node_` 开头但缺 create 记录的
4836
+ // (②,显式传 daemon 是文档化的出路)保持原行为。
4837
+ if (!authoritativeDaemonId && !args.child_node_id.startsWith("node_")) {
4838
+ return explainUnresolvableDaemon(args.child_node_id);
4839
+ }
4829
4840
  if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
4830
4841
  return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
4831
4842
  }