@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.72

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/README.md CHANGED
@@ -150,7 +150,7 @@ delivered/acked/running → reassign → delivered (new agent)
150
150
 
151
151
  ## PostgreSQL (community extension point — not on the maintained roadmap)
152
152
 
153
- > **PostgreSQL support is still in development and does not work yet** (PostgreSQL 支持尚在开发中,目前不可用). With `DATABASE_URL=postgres://…` the hub exits while creating its schema. Progress: [RFC-039](https://github.com/sleep2agi/agent-network/blob/main/docs/rfcs/RFC-039-hub-postgresql-backend.md).
153
+ > **PostgreSQL support is still in development and does not work yet** (PostgreSQL 支持尚在开发中,目前不可用). With `DATABASE_URL=postgres://…` the hub builds its schema, but `startHub()` refuses to run on PostgreSQL unless you also set `COMMHUB_PG_EXPERIMENTAL=1` (experimental, for testing only: it switches on the scheduler, task runtime evidence and side-thread commands on PostgreSQL before their PostgreSQL tests exist; RFC-039 S4 removes the flag). Progress: [RFC-039](https://github.com/sleep2agi/agent-network/blob/main/docs/rfcs/RFC-039-hub-postgresql-backend.md).
154
154
 
155
155
  > v0.8+ product direction is **SQLite only** (see [docs/v3-postgresql-design.md banner](https://github.com/sleep2agi/agent-network/blob/main/docs/v3-postgresql-design.md)). The PostgreSQL adapter interface is preserved as a community extension point — no E2E coverage on the current stable line; **not recommended for mainline production**.
156
156
 
@@ -276,7 +276,7 @@ main 上 66 处写的是 `SOURCE_COMMIT`,另有若干写成 `TEST<编号>_SOURCE
276
276
  ## Not verified
277
277
 
278
278
  - `/api/license*` — experimental legacy trial/pro-license endpoints.
279
- - PostgreSQL backend — in development, does not work yet (hub exits while creating its schema; RFC-039).
279
+ - PostgreSQL backend — in development, does not work yet (the hub refuses to start on it without the experimental opt-in; RFC-039).
280
280
  - Telegram / WeChat / Feishu channel endpoints — channel code exists, but only Telegram-oriented agent-node paths are actively exercised.
281
281
 
282
282
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.71",
3
+ "version": "0.9.0-preview.72",
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",
@@ -174,4 +174,55 @@ describe("DELETE /api/networks/:id — hub admin override for empty foreign netw
174
174
  expect(res.status).toBe(200);
175
175
  expect(await res.json()).toEqual({ ok: true });
176
176
  });
177
+
178
+ // #2144 —— Agent 分组(#2131)的三张表。
179
+ function addGroup(netId: string, tag: string): string {
180
+ const groupId = `grp-${tag}-${stamp}`;
181
+ db.run("INSERT INTO agent_groups (group_id, network_id, name) VALUES (?1, ?2, ?3)", [groupId, netId, `g-${tag}`]);
182
+ db.run("INSERT INTO agent_group_members (group_id, node_id) VALUES (?1, ?2)", [groupId, `node-${tag}-${stamp}`]);
183
+ db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, groupId]);
184
+ return groupId;
185
+ }
186
+ function groupRows(netId: string, groupId: string) {
187
+ return {
188
+ agent_groups: rowCount("agent_groups", netId),
189
+ agent_group_members: db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM agent_group_members WHERE group_id = ?1", groupId)!.cnt,
190
+ network_member_group_grants: rowCount("network_member_group_grants", netId),
191
+ };
192
+ }
193
+
194
+ test("#2144 admin is refused with 409 counts.agent_groups when the network has an agent group", async () => {
195
+ const netId = memberNetwork("grouped");
196
+ const groupId = addGroup(netId, "admin-blocked");
197
+ const res = await del(netId, adminToken);
198
+ const body = await res.json() as any;
199
+ expect(res.status).toBe(409);
200
+ expect(body.counts).toEqual({ agent_groups: 1 });
201
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
202
+ expect(groupRows(netId, groupId)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
203
+ });
204
+
205
+ test("#2144 admin delete of an otherwise-empty network cleans orphan group grants", async () => {
206
+ const netId = memberNetwork("orphan-grant");
207
+ db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, `grp-gone-${stamp}`]);
208
+ const res = await del(netId, adminToken);
209
+ const body = await res.json() as any;
210
+ expect(res.status).toBe(200);
211
+ expect(body.cleaned.network_member_group_grants).toBe(1);
212
+ expect(rowCount("network_member_group_grants", netId)).toBe(0);
213
+ });
214
+
215
+ test("#2144 owner delete removes the network's groups, group members and group grants, and leaves other networks' groups", async () => {
216
+ const netId = memberNetwork("owner-grouped");
217
+ const groupId = addGroup(netId, "owner");
218
+ const otherNet = memberNetwork("owner-grouped-other");
219
+ const otherGroup = addGroup(otherNet, "other");
220
+
221
+ const res = await del(netId, memberToken);
222
+ expect(res.status).toBe(200);
223
+ expect(await res.json()).toEqual({ ok: true });
224
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
225
+ expect(groupRows(netId, groupId)).toEqual({ agent_groups: 0, agent_group_members: 0, network_member_group_grants: 0 });
226
+ expect(groupRows(otherNet, otherGroup)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
227
+ });
177
228
  });
@@ -0,0 +1,210 @@
1
+ // visibleAgents() batched into one query. The old implementation (kept here verbatim as the
2
+ // reference) ran one nodes ∪ sessions lookup per node_id grant — ~300 queries per call for a
3
+ // restricted member with 300 grants, on every restricted /api/status poll. The new one must return
4
+ // IDENTICAL output (same members, same order) on randomized fixtures.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-agent-access-batch-"));
12
+ process.env.COMMHUB_DB = join(dir, "hub.db");
13
+
14
+ let db: any;
15
+ let visibleAgents: (userId: string, networkId: string) => any;
16
+ let listAgentGrants: (networkId: string, userId: string) => any[];
17
+ let CHUNK = 0;
18
+
19
+ function visibleAgentsReference(userId: string, networkId: string) {
20
+ const aliases = new Set<string>();
21
+ const nodeIds = new Set<string>();
22
+ const messageAliases = new Set<string>();
23
+ const messageNodeIds = new Set<string>();
24
+ // Group grants (#2131) expanded exactly as agent-access.ts groupGrantNodes() does, appended after
25
+ // the direct grants — the order the old loop saw them in.
26
+ const groupNodes = db.all(
27
+ `SELECT m.node_id, g.can_message
28
+ FROM network_member_group_grants g
29
+ JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
30
+ JOIN agent_group_members m ON m.group_id = g.group_id
31
+ JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
32
+ WHERE g.network_id = ?1 AND g.user_id = ?2`,
33
+ networkId, userId,
34
+ ).map((row: any) => ({ node_id: row.node_id, alias: null, can_message: row.can_message === 1 }));
35
+ for (const grant of [...listAgentGrants(networkId, userId), ...groupNodes]) {
36
+ const grantAliases: string[] = [];
37
+ if (grant.node_id) {
38
+ nodeIds.add(grant.node_id);
39
+ if (grant.can_message) messageNodeIds.add(grant.node_id);
40
+ for (const row of db.all(
41
+ `SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2
42
+ UNION SELECT alias FROM sessions WHERE node_id = ?1 AND network_id = ?2`,
43
+ grant.node_id, networkId,
44
+ )) {
45
+ if (row.alias) grantAliases.push(row.alias);
46
+ }
47
+ } else if (grant.alias) {
48
+ grantAliases.push(grant.alias);
49
+ }
50
+ for (const alias of grantAliases) {
51
+ aliases.add(alias);
52
+ if (grant.can_message) messageAliases.add(alias);
53
+ }
54
+ }
55
+ return { aliases: [...aliases], nodeIds: [...nodeIds], messageAliases: [...messageAliases], messageNodeIds: [...messageNodeIds] };
56
+ }
57
+
58
+ // Deterministic PRNG so a failure is reproducible from the printed seed.
59
+ function rng(seed: number) {
60
+ let s = seed >>> 0;
61
+ return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; };
62
+ }
63
+
64
+ let seq = 0;
65
+ function seedFixture(seed: number, opts: { nodes: number; grants: number; groups?: number }) {
66
+ const r = rng(seed);
67
+ const pick = <T,>(xs: T[]) => xs[Math.floor(r() * xs.length)];
68
+ const tag = `s${seed}_${seq++}`;
69
+ db.exec("BEGIN");
70
+ const net = `net_${tag}`, other = `net_${tag}_other`, user = `u_${tag}`;
71
+ db.run(`INSERT INTO users (user_id, username, password_hash) VALUES (?1, ?1, 'x')`, [user]);
72
+ const nodeIds: string[] = [];
73
+ const legacyAliases: string[] = [];
74
+ for (let i = 0; i < opts.nodes; i++) {
75
+ const nodeId = `node_${tag}_${i}`;
76
+ nodeIds.push(nodeId);
77
+ const alias = `agent-${tag}-${i}`;
78
+ const shape = r();
79
+ // nodes row (sometimes missing, sometimes NULL alias)
80
+ if (shape > 0.1) db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)`, [nodeId, alias, shape > 0.15 ? alias : null, net]);
81
+ // sessions rows: same alias, a renamed alias (rename window), several, or none
82
+ const sessions = r() < 0.2 ? 0 : 1 + Math.floor(r() * 3);
83
+ for (let k = 0; k < sessions; k++) {
84
+ const sAlias = k === 0 && r() < 0.6 ? alias : `${alias}-renamed-${k}`;
85
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id) VALUES (?1, ?2, ?3, 'idle', ?4)`, [`r_${tag}_${i}_${k}`, sAlias, nodeId, net]);
86
+ }
87
+ // same node_id in another network must never leak in
88
+ if (r() < 0.3) db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id) VALUES (?1, ?2, ?3, 'idle', ?4)`, [`r_${tag}_${i}_x`, `${alias}-elsewhere`, nodeId, other]);
89
+ }
90
+ for (let i = 0; i < Math.ceil(opts.nodes / 5); i++) {
91
+ const alias = `legacy-${tag}-${i}`;
92
+ legacyAliases.push(alias);
93
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES (?1, ?2, 'idle', ?3)`, [`rl_${tag}_${i}`, alias, net]);
94
+ }
95
+ const usedAliases = new Set<string>();
96
+ // distinct nodes in a shuffled order (the grant table has one row per node per member)
97
+ const order = nodeIds.map(n => [r(), n] as const).sort((a, b) => a[0] - b[0]).map(([, n]) => n);
98
+ for (let i = 0; i < opts.grants; i++) {
99
+ const byNode = r() < 0.8;
100
+ const can = r() < 0.5 ? 1 : 0;
101
+ if (byNode) {
102
+ const nodeId = r() < 0.05 ? `node_${tag}_ghost_${i}` : order.pop();
103
+ if (!nodeId) continue;
104
+ db.run(`INSERT INTO network_member_agent_grants (network_id, user_id, node_id, alias, can_message) VALUES (?1, ?2, ?3, NULL, ?4)`, [net, user, nodeId, can]);
105
+ } else {
106
+ const alias = pick(legacyAliases);
107
+ if (usedAliases.has(alias)) continue;
108
+ usedAliases.add(alias);
109
+ db.run(`INSERT INTO network_member_agent_grants (network_id, user_id, node_id, alias, can_message) VALUES (?1, ?2, NULL, ?3, ?4)`, [net, user, alias, can]);
110
+ }
111
+ }
112
+ // Groups (#2131): some granted, some not; members include nodes also granted directly (with a
113
+ // different can_message), ghost nodes with no nodes row, and a node that lives in another network.
114
+ const groups = opts.groups ?? 0;
115
+ for (let gi = 0; gi < groups; gi++) {
116
+ const groupId = `grp_${tag}_${gi}`;
117
+ const groupNet = r() < 0.1 ? other : net;
118
+ db.run(`INSERT INTO agent_groups (group_id, network_id, name) VALUES (?1, ?2, ?3)`, [groupId, groupNet, `group ${tag} ${gi}`]);
119
+ const size = 1 + Math.floor(r() * 8);
120
+ const members = new Set<string>();
121
+ for (let k = 0; k < size; k++) members.add(r() < 0.05 ? `node_${tag}_ghostmember_${gi}_${k}` : pick(nodeIds));
122
+ if (r() < 0.2) {
123
+ const foreign = `node_${tag}_foreign_${gi}`;
124
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?1, ?2, ?3)`, [foreign, `foreign-${tag}-${gi}`, other]);
125
+ members.add(foreign);
126
+ }
127
+ for (const m of members) db.run(`INSERT INTO agent_group_members (group_id, node_id) VALUES (?1, ?2)`, [groupId, m]);
128
+ if (r() < 0.7) db.run(`INSERT INTO network_member_group_grants (network_id, user_id, group_id, can_message) VALUES (?1, ?2, ?3, ?4)`, [net, user, groupId, r() < 0.5 ? 1 : 0]);
129
+ }
130
+ db.exec("COMMIT");
131
+ return { net, user };
132
+ }
133
+
134
+ beforeAll(async () => {
135
+ ({ db } = await import("./db.js"));
136
+ ({ visibleAgents, listAgentGrants, VISIBLE_AGENTS_CHUNK: CHUNK } = await import("./agent-access.js") as any);
137
+ });
138
+
139
+ afterAll(() => {
140
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
141
+ });
142
+
143
+ describe("visibleAgents batched = reference, on randomized fixtures", () => {
144
+ test("200 random fixtures: identical output (members and order)", () => {
145
+ for (let seed = 1; seed <= 200; seed++) {
146
+ const { net, user } = seedFixture(seed, { nodes: 5 + (seed % 40), grants: seed % 7 === 0 ? 0 : 3 + (seed % 50), groups: seed % 3 === 0 ? 0 : 1 + (seed % 12) });
147
+ const got = visibleAgents(user, net);
148
+ const want = visibleAgentsReference(user, net);
149
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
150
+ throw new Error(`seed ${seed} differs:\nnew ${JSON.stringify(got)}\nold ${JSON.stringify(want)}`);
151
+ }
152
+ }
153
+ }, 120_000);
154
+
155
+ test("the fixtures exercise every shape (rename window, NULL alias, ghost node, legacy alias, other network, groups)", () => {
156
+ const { net, user } = seedFixture(4242, { nodes: 60, grants: 80, groups: 15 });
157
+ const groupNodes = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", net, user)!.n;
158
+ expect(groupNodes).toBeGreaterThan(0);
159
+ const got = visibleAgents(user, net);
160
+ expect(got.aliases.some((a: string) => a.includes("-renamed-"))).toBe(true);
161
+ expect(got.aliases.some((a: string) => a.startsWith("legacy-"))).toBe(true);
162
+ expect(got.aliases.some((a: string) => a.endsWith("-elsewhere"))).toBe(false);
163
+ expect(got.messageAliases.length).toBeLessThan(got.aliases.length);
164
+ expect(JSON.stringify(got)).toBe(JSON.stringify(visibleAgentsReference(user, net)));
165
+ });
166
+
167
+ test("groups only (no direct grants): identical output, group nodes visible", () => {
168
+ const { net, user } = seedFixture(9090, { nodes: 80, grants: 0, groups: 25 });
169
+ const got = visibleAgents(user, net);
170
+ expect(got.nodeIds.length).toBeGreaterThan(0);
171
+ expect(JSON.stringify(got)).toBe(JSON.stringify(visibleAgentsReference(user, net)));
172
+ });
173
+
174
+ test("no grants → all empty; unknown user → all empty", () => {
175
+ const { net } = seedFixture(7, { nodes: 10, grants: 0 });
176
+ expect(visibleAgents("nobody", net)).toEqual({ aliases: [], nodeIds: [], messageAliases: [], messageNodeIds: [] });
177
+ });
178
+
179
+ test("query-count guard: a fixed number of queries per call, not one per grant (#2133)", () => {
180
+ const { net, user } = seedFixture(301, { nodes: 400, grants: 420, groups: 30 });
181
+ const grants = listAgentGrants(net, user).length;
182
+ expect(grants).toBeGreaterThanOrEqual(300);
183
+ const calls = { all: 0, get: 0 };
184
+ const all = db.all.bind(db), get = db.get.bind(db);
185
+ db.all = (...a: any[]) => { calls.all++; return all(...a); };
186
+ db.get = (...a: any[]) => { calls.get++; return get(...a); };
187
+ try { visibleAgents(user, net); } finally { db.all = all; db.get = get; }
188
+ // direct grants + group grants + ceil(nodes / chunk) alias reads
189
+ expect(calls.all + calls.get).toBeLessThanOrEqual(2 + Math.ceil(grants / CHUNK));
190
+ expect(calls.all + calls.get).toBeLessThanOrEqual(3);
191
+ }, 60_000);
192
+
193
+ test("more granted nodes than one chunk: same output across the chunk boundary", () => {
194
+ const { net, user } = seedFixture(777, { nodes: CHUNK + 120, grants: CHUNK + 200 });
195
+ expect(listAgentGrants(net, user).filter((g: any) => g.node_id).length).toBeGreaterThan(CHUNK);
196
+ expect(JSON.stringify(visibleAgents(user, net))).toBe(JSON.stringify(visibleAgentsReference(user, net)));
197
+ }, 120_000);
198
+
199
+ test("timing at 300 grants (printed; new must be faster)", () => {
200
+ const { net, user } = seedFixture(300, { nodes: 400, grants: 420 });
201
+ const grants = listAgentGrants(net, user).length;
202
+ expect(grants).toBeGreaterThanOrEqual(300);
203
+ const time = (fn: () => unknown, n = 50) => { fn(); const t0 = performance.now(); for (let i = 0; i < n; i++) fn(); return (performance.now() - t0) / n; };
204
+ const oldMs = time(() => visibleAgentsReference(user, net));
205
+ const newMs = time(() => visibleAgents(user, net));
206
+ console.log(`visibleAgents @ ${grants} grants: old ${oldMs.toFixed(2)} ms/call, new ${newMs.toFixed(2)} ms/call (${(oldMs / newMs).toFixed(1)}x)`);
207
+ expect(JSON.stringify(visibleAgents(user, net))).toBe(JSON.stringify(visibleAgentsReference(user, net)));
208
+ expect(newMs).toBeLessThan(oldMs);
209
+ }, 120_000);
210
+ });
@@ -88,6 +88,9 @@ export function listAgentGrants(networkId: string, userId: string): AgentGrant[]
88
88
  * node_id 授权同时带出:nodes 表里这个节点的 alias、sessions 表里同 node_id 的会话 alias
89
89
  * (改名后 sessions 先于 nodes 更新的窗口里两者可能不同,两个都算)。都限定在同一网络。
90
90
  */
91
+ /** visibleAgents 一次查询里最多放多少个节点 id(绑定参数上限;1000 个授权节点 = 2 次查询)。 */
92
+ export const VISIBLE_AGENTS_CHUNK = 500;
93
+
91
94
  export function visibleAgents(userId: string, networkId: string): VisibleAgents {
92
95
  const aliases = new Set<string>();
93
96
  const nodeIds = new Set<string>();
@@ -96,20 +99,34 @@ export function visibleAgents(userId: string, networkId: string): VisibleAgents
96
99
  // RFC-038 §8:组授权展开成「该组此刻的成员节点」—— 每次现算,组里新加的节点下一次请求就可见。
97
100
  // 只并同网络、且 nodes 表里还在的节点;可对话取并集(直接授权或任一组给了就算)。
98
101
  const grants: AgentGrant[] = [...listAgentGrants(networkId, userId), ...groupGrantNodes(networkId, userId)];
102
+ // #2133:所有授权节点的 alias 一次查完(原来每个节点单独查一次 nodes ∪ sessions:300 条授权 ≈ 300 次查询,
103
+ // 而受限成员的每次 /api/status 轮询都走这里)。结果与原实现逐字一致:授权按上面数组的顺序,
104
+ // 同一节点内的 alias 按 UNION 的去重升序 —— agent-access-batch.test.ts 在随机夹具上逐字比对。
105
+ // 分块只为绑定参数个数的上限;同一节点的所有 alias 必在同一块里,顺序不受分块影响。
106
+ const grantedNodeIds = [...new Set(grants.map((grant) => grant.node_id).filter((id): id is string => !!id))];
107
+ const aliasesByNode = new Map<string, string[]>();
108
+ for (let start = 0; start < grantedNodeIds.length; start += VISIBLE_AGENTS_CHUNK) {
109
+ const chunk = grantedNodeIds.slice(start, start + VISIBLE_AGENTS_CHUNK);
110
+ const inList = chunk.map((_, i) => `?${i + 2}`).join(", ");
111
+ for (const row of db.all<{ node_id: string; alias: string | null }>(
112
+ `SELECT node_id, alias FROM nodes WHERE network_id = ?1 AND node_id IN (${inList})
113
+ UNION SELECT node_id, alias FROM sessions WHERE network_id = ?1 AND node_id IN (${inList})
114
+ ORDER BY node_id, alias`,
115
+ networkId, ...chunk,
116
+ )) {
117
+ if (!row.alias) continue;
118
+ const list = aliasesByNode.get(row.node_id);
119
+ if (list) list.push(row.alias); else aliasesByNode.set(row.node_id, [row.alias]);
120
+ }
121
+ }
99
122
  for (const grant of grants) {
100
- const grantAliases: string[] = [];
123
+ let grantAliases: readonly string[] = [];
101
124
  if (grant.node_id) {
102
125
  nodeIds.add(grant.node_id);
103
126
  if (grant.can_message) messageNodeIds.add(grant.node_id);
104
- for (const row of db.all<{ alias: string | null }>(
105
- `SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2
106
- UNION SELECT alias FROM sessions WHERE node_id = ?1 AND network_id = ?2`,
107
- grant.node_id, networkId,
108
- )) {
109
- if (row.alias) grantAliases.push(row.alias);
110
- }
127
+ grantAliases = aliasesByNode.get(grant.node_id) ?? [];
111
128
  } else if (grant.alias) {
112
- grantAliases.push(grant.alias);
129
+ grantAliases = [grant.alias];
113
130
  }
114
131
  for (const alias of grantAliases) {
115
132
  aliases.add(alias);
@@ -368,6 +368,14 @@ describe("requirements: agent references alice cannot see are hidden and protect
368
368
  const r = await get(aliceToken, `/api/requirements?network_id=${NET}&agent_owner=node:${Z.node}`);
369
369
  expect(r.body.requirements).toEqual([]);
370
370
  });
371
+ test("q= does not match on a hidden agent's name (no 'who owns this card' probe); admin's q= does", async () => {
372
+ const zName = db.get<{ name: string }>("SELECT COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE node_id = ?1", Z.node)!.name;
373
+ const ids = async (token: string, q: string) => (await get(token, `/api/requirements?network_id=${NET}&q=${encodeURIComponent(q)}`)).body.requirements.map((r: any) => r.id);
374
+ expect(await ids(aliceToken, zName)).not.toContain(reqId);
375
+ expect(await ids(adminToken, zName)).toContain(reqId);
376
+ // 看得见的字段照样能搜到(标题)
377
+ expect(await ids(aliceToken, "card")).toContain(reqId);
378
+ });
371
379
  });
372
380
 
373
381
  describe("cannot be abused", () => {
@@ -128,6 +128,29 @@ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
128
128
  expect(r.delta).toBeLessThanOrEqual(bound(r.a));
129
129
  }, PERF_TIMEOUT_MS);
130
130
 
131
+ // #2133:visibleAgents() 以前每个授权节点查一次 alias(300 条 ≈ 28–43 ms)。这两条钉住它不再回到 N+1:
132
+ // 查询条数(直接授权 1 + 组授权 1 + alias 1)是主判据,不受机器负载影响;绝对上限只兜底 ——
133
+ // 本机实测 ≈2.6–3.7 ms(负载 ~5),15 ms 给 test638 那种 --cpus=1 双份并发留足余量,仍远低于 N+1 的 28 ms。
134
+ const ABS_MS = 15;
135
+ test(`visibleAgents() at 300 grants stays under ${ABS_MS} ms (direct and via groups)`, () => {
136
+ const t = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, 40);
137
+ console.log(`[perf] visibleAgents absolute: direct-300=${t.a.toFixed(2)}ms groups-50x6=${t.b.toFixed(2)}ms bound=${ABS_MS}ms`);
138
+ expect(t.a).toBeLessThan(ABS_MS);
139
+ expect(t.b).toBeLessThan(ABS_MS);
140
+ });
141
+
142
+ test("visibleAgents() makes a fixed number of queries, not one per granted node (#2133)", () => {
143
+ for (const who of [bob, alice]) {
144
+ let queries = 0;
145
+ const all = db.all.bind(db), get = db.get.bind(db);
146
+ (db as any).all = (...a: any[]) => { queries++; return (all as any)(...a); };
147
+ (db as any).get = (...a: any[]) => { queries++; return (get as any)(...a); };
148
+ try { visibleAgents(who.id, NET); } finally { (db as any).all = all; (db as any).get = get; }
149
+ console.log(`[perf] visibleAgents queries (${who === bob ? "direct-300" : "groups-50x6"}): ${queries}`);
150
+ expect(queries).toBeLessThanOrEqual(3);
151
+ }
152
+ });
153
+
131
154
  test(`受限成员 GET /api/status:组授权比等量直接授权多出 ≤ max(${BOUND_MS} ms, 基线 15%)`, async () => {
132
155
  const hit = async (token: string) => {
133
156
  const r = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: json(token) });
package/src/auth.ts CHANGED
@@ -438,12 +438,15 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
438
438
  // Check if any sessions/tasks still reference this network
439
439
  const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
440
440
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
441
- db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
442
- // PR #519 codex catch: leaving membership rows behind made deleted
443
- // networks count toward getUserNetworkIds — a stale row could flip a
444
- // single-network user to "ambiguous" or auto-resolve writes INTO the
445
- // deleted network. Remove memberships with the network.
446
- db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
441
+ db.transaction(() => {
442
+ db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
443
+ // PR #519 codex catch: leaving membership rows behind made deleted
444
+ // networks count toward getUserNetworkIds — a stale row could flip a
445
+ // single-network user to "ambiguous" or auto-resolve writes INTO the
446
+ // deleted network. Remove memberships with the network.
447
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
448
+ deleteNetworkAgentGroups(networkId); // #2144
449
+ });
447
450
  return { ok: true };
448
451
  }
449
452
 
@@ -814,6 +817,8 @@ const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
814
817
  ["network_secrets", "SELECT COUNT(*) AS cnt FROM network_secrets WHERE network_id = ?1"],
815
818
  ["skillhub_skills", "SELECT COUNT(*) AS cnt FROM skillhub_skills WHERE network_id = ?1"],
816
819
  ["side_chats", "SELECT COUNT(*) AS cnt FROM side_chats WHERE network_id = ?1"],
820
+ // #2144 —— 组是管理员手工建的配置,和 requirements 一样算内容:有组就拒,别替人删掉。
821
+ ["agent_groups", "SELECT COUNT(*) AS cnt FROM agent_groups WHERE network_id = ?1"],
817
822
  ["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
818
823
  ["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
819
824
  ];
@@ -864,7 +869,18 @@ export function adminDeleteEmptyNetwork(networkId: string, extraCounts: Record<s
864
869
  const changes = db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [networkId]).changes;
865
870
  if (changes > 0) cleaned[table] = changes;
866
871
  }
872
+ for (const [table, changes] of Object.entries(deleteNetworkAgentGroups(networkId))) if (changes > 0) cleaned[table] = changes;
867
873
  db.run("DELETE FROM networks WHERE network_id = ?1", [networkId]);
868
874
  return { ok: true, owner_id: net.owner_id, network_name: net.network_name, cleaned };
869
875
  });
870
876
  }
877
+
878
+ // #2144 —— Agent 分组(#2131)的三张表跟着网络一起删。agent_group_members 没有 network_id 列,
879
+ // 只能按该网络的 group_id 删,所以必须在删 agent_groups 之前删它。owner 删网络与管理员删空网络共用。
880
+ export function deleteNetworkAgentGroups(networkId: string): Record<string, number> {
881
+ return {
882
+ network_member_group_grants: db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1", [networkId]).changes,
883
+ agent_group_members: db.run("DELETE FROM agent_group_members WHERE group_id IN (SELECT group_id FROM agent_groups WHERE network_id = ?1)", [networkId]).changes,
884
+ agent_groups: db.run("DELETE FROM agent_groups WHERE network_id = ?1", [networkId]).changes,
885
+ };
886
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  assertSafeTestDatabaseEnv,
4
+ redactPgUrl,
4
5
  resolveDatabaseTarget,
5
6
  } from "./db-adapter";
6
7
 
@@ -83,3 +84,65 @@ describe("#435 inherited DATABASE_URL guard", () => {
83
84
  }
84
85
  });
85
86
  });
87
+
88
+ describe("RFC-039 §9 COMMHUB_TEST_PG_URL (test-only PostgreSQL)", () => {
89
+ const TEST = { ...BASE_ENV, NODE_ENV: "test" };
90
+ const ok = (url: string) => expect(resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url })).toEqual({ kind: "postgres", url });
91
+ const refused = (url: string, why: RegExp) => {
92
+ let message = "";
93
+ try { resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url }); } catch (e: any) { message = String(e?.message ?? e); }
94
+ expect(message).toMatch(/REFUSING COMMHUB_TEST_PG_URL/);
95
+ expect(message).toMatch(why);
96
+ // Credentials never reach an error message.
97
+ expect(message).not.toContain("s3cret");
98
+ expect(message).not.toContain("tester");
99
+ };
100
+
101
+ test("loopback host + anet_*_test database is accepted under NODE_ENV=test", () => {
102
+ ok("postgres://tester:s3cret@127.0.0.1:5432/anet_sched_test");
103
+ ok("postgresql://tester:s3cret@localhost/anet_side_thread_test");
104
+ ok("postgres://tester:s3cret@[::1]:6543/anet_x_test?connect_timeout=2&sslmode=disable");
105
+ });
106
+
107
+ test("ignored outside NODE_ENV=test", () => {
108
+ expect(resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "production", COMMHUB_DB: "/tmp/i.db", COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test" }))
109
+ .toEqual({ kind: "sqlite", path: "/tmp/i.db" });
110
+ });
111
+
112
+ test("inherited DATABASE_URL still refuses even when COMMHUB_TEST_PG_URL is set", () => {
113
+ expect(() => resolveDatabaseTarget({
114
+ ...TEST,
115
+ DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
116
+ COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test",
117
+ })).toThrow(/REFUSING to honor inherited DATABASE_URL/);
118
+ });
119
+
120
+ test("refuses a value that is not a URL or not postgres", () => {
121
+ refused("not a url", /not a URL/);
122
+ refused("mysql://tester:s3cret@127.0.0.1/anet_x_test", /scheme/);
123
+ });
124
+
125
+ test("refuses every host that is not literally loopback", () => {
126
+ for (const host of ["10.0.0.5", "db.internal", "localhost.evil.example", "127.0.0.2", "127.0.0.1,evil.example", "LOCALHOST", "%2Ftmp"]) {
127
+ refused(`postgres://tester:s3cret@${host}/anet_x_test`, /host must be/);
128
+ }
129
+ });
130
+
131
+ test("refuses database names outside /^anet_[a-z0-9_]*_test$/", () => {
132
+ for (const name of ["commhub", "anet_x", "anet_x_test2", "ANET_X_TEST", "anet-x_test", "anet_x_test/extra", ""]) {
133
+ refused(`postgres://tester:s3cret@127.0.0.1/${name}`, /database name/);
134
+ }
135
+ });
136
+
137
+ test("refuses connection-steering query parameters", () => {
138
+ refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?host=db.internal", /"host" is not allowed/);
139
+ refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?options=-csearch_path%3Dprod", /"options" is not allowed/);
140
+ });
141
+
142
+ test("redactPgUrl drops user and password", () => {
143
+ const out = redactPgUrl("postgres://tester:s3cret@127.0.0.1:5432/anet_x_test");
144
+ expect(out).not.toContain("tester");
145
+ expect(out).not.toContain("s3cret");
146
+ expect(out).toContain("127.0.0.1:5432/anet_x_test");
147
+ });
148
+ });
@@ -15,6 +15,12 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
15
15
  expect(out).not.toContain("TIMESTAMP");
16
16
  });
17
17
 
18
+ test("strftime with milliseconds keeps SQLite's %f shape", () => {
19
+ expect(sqliteToPostgres("VALUES (strftime('%Y-%m-%d %H:%M:%f', 'now'))")).toBe(
20
+ "VALUES (to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS'))",
21
+ );
22
+ });
23
+
18
24
  test("literal offset", () => {
19
25
  expect(sqliteToPostgres("x < datetime('now', '+14 days')")).toBe(
20
26
  "x < to_char((NOW() + INTERVAL '14 days') AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
@@ -27,6 +33,11 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
27
33
  );
28
34
  });
29
35
 
36
+ test("INTEGER → BIGINT (epoch-ms columns), identifiers containing integer untouched", () => {
37
+ expect(sqliteToPostgres("created_at INTEGER NOT NULL, integer_ref TEXT")).toBe("created_at BIGINT NOT NULL, integer_ref TEXT");
38
+ expect(sqliteToPostgres("CAST(x AS INTEGER)")).toBe("CAST(x AS BIGINT)");
39
+ });
40
+
30
41
  test("BLOB → BYTEA, identifiers containing blob untouched", () => {
31
42
  expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
32
43
  });
@@ -34,7 +45,7 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
34
45
  test("placeholders and AUTOINCREMENT unchanged in behaviour", () => {
35
46
  expect(sqliteToPostgres("SELECT * FROM t WHERE a = ?1 AND b = ?2")).toBe("SELECT * FROM t WHERE a = $1 AND b = $2");
36
47
  expect(sqliteToPostgres("a = ? AND b = ?")).toBe("a = $1 AND b = $2");
37
- expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id SERIAL PRIMARY KEY");
48
+ expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id BIGSERIAL PRIMARY KEY");
38
49
  });
39
50
  });
40
51