@sleep2agi/commhub-server 0.9.0-preview.70 → 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 +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +222 -11
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +8 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/auth.ts +98 -7
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +123 -0
- package/src/db-adapter.ts +246 -65
- package/src/db.ts +46 -3
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +139 -23
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +93 -29
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +118 -12
- package/src/side-thread-command-transport.test.ts +14 -4
- package/src/side-thread-command-transport.ts +4 -4
- package/src/side-thread.ts +4 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +16 -13
package/README.md
CHANGED
|
@@ -150,9 +150,11 @@ 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 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
|
+
|
|
153
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**.
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
Design intent (not working today): setting `DATABASE_URL` switches to PostgreSQL, and the SQL layer translates SQLite-isms (datetime, parameter placeholders). Requires `bun add pg`.
|
|
156
158
|
|
|
157
159
|
```bash
|
|
158
160
|
DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-server
|
|
@@ -166,7 +168,7 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
|
|
|
166
168
|
| `HOST` | `0.0.0.0` in the server package, `127.0.0.1` when launched by `anet hub start` | listen address |
|
|
167
169
|
| `COMMHUB_AUTH_TOKEN` | (none) | Bearer token gate (legacy) |
|
|
168
170
|
| `COMMHUB_DB` | `~/.commhub/commhub.db` | SQLite path |
|
|
169
|
-
| `DATABASE_URL` | (none) | switches to PostgreSQL when set
|
|
171
|
+
| `DATABASE_URL` | (none) | switches to PostgreSQL when set — **does not work yet**, see above |
|
|
170
172
|
|
|
171
173
|
## Running the unit tests
|
|
172
174
|
|
|
@@ -274,7 +276,7 @@ main 上 66 处写的是 `SOURCE_COMMIT`,另有若干写成 `TEST<编号>_SOURCE
|
|
|
274
276
|
## Not verified
|
|
275
277
|
|
|
276
278
|
- `/api/license*` — experimental legacy trial/pro-license endpoints.
|
|
277
|
-
- PostgreSQL backend —
|
|
279
|
+
- PostgreSQL backend — in development, does not work yet (the hub refuses to start on it without the experimental opt-in; RFC-039).
|
|
278
280
|
- Telegram / WeChat / Feishu channel endpoints — channel code exists, but only Telegram-oriented agent-node paths are actively exercised.
|
|
279
281
|
|
|
280
282
|
## License
|
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.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",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Hub 管理员删别人的**空**网络(清理旧测试账号留下的个人网络)。
|
|
2
|
+
// 真 HTTP:走 DELETE /api/networks/:id,库和上传目录都是本文件私有的临时目录。
|
|
3
|
+
// bun test server/src/admin-delete-network-http.test.ts
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PRIVATE_DIR = mkdtempSync(join(tmpdir(), "anet-admin-delete-network-"));
|
|
10
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DIR, "hub.db");
|
|
11
|
+
process.env.COMMHUB_UPLOADS_DIR = join(PRIVATE_DIR, "uploads");
|
|
12
|
+
|
|
13
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
14
|
+
let base = "";
|
|
15
|
+
let db: typeof import("./db.js").db;
|
|
16
|
+
let auth: typeof import("./auth.js");
|
|
17
|
+
let adminToken = "";
|
|
18
|
+
let adminUserId = "";
|
|
19
|
+
let adminNetworkId = "";
|
|
20
|
+
let memberToken = "";
|
|
21
|
+
let memberUserId = "";
|
|
22
|
+
let memberNodeToken = "";
|
|
23
|
+
const stamp = Date.now();
|
|
24
|
+
|
|
25
|
+
function del(networkId: string, token: string) {
|
|
26
|
+
return fetch(`${base}/api/networks/${networkId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memberNetwork(name: string): string {
|
|
30
|
+
const created = auth.createNetwork(memberUserId, `${name}-${stamp}`);
|
|
31
|
+
expect(created.ok).toBe(true);
|
|
32
|
+
return created.network_id!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowCount(table: string, networkId: string): number {
|
|
36
|
+
return db.get<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM ${table} WHERE network_id = ?1`, networkId)!.cnt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 同 admin-networks-http.test.ts:register() 跑 KDF + bootServer,给 30s 而不是 bun 默认 5s。
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
({ db } = await import("./db.js"));
|
|
42
|
+
auth = await import("./auth.js");
|
|
43
|
+
|
|
44
|
+
// 第一个注册的是 Hub 管理员;第二个是普通用户,不在管理员的网络里。
|
|
45
|
+
const admin = auth.register(`admin_delnet_${stamp}`, "AdminDelNet-Strong-1!", undefined, "admin");
|
|
46
|
+
expect(admin.ok).toBe(true);
|
|
47
|
+
adminToken = admin.token!;
|
|
48
|
+
adminNetworkId = admin.network_id!;
|
|
49
|
+
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `admin_delnet_${stamp}`)!.user_id;
|
|
50
|
+
|
|
51
|
+
const member = auth.register(`member_delnet_${stamp}`, "MemberDelNet-Strong-1!", undefined, "member");
|
|
52
|
+
expect(member.ok).toBe(true);
|
|
53
|
+
memberToken = member.token!;
|
|
54
|
+
memberUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `member_delnet_${stamp}`)!.user_id;
|
|
55
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", adminUserId)!.role).toBe("admin");
|
|
56
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", memberUserId)!.role).not.toBe("admin");
|
|
57
|
+
// free 档只能拥有 2 个网络;本文件要给它建七八个。plan 只影响配额,不影响任何权限判定。
|
|
58
|
+
db.run("UPDATE users SET plan = 'pro' WHERE user_id = ?1", [memberUserId]);
|
|
59
|
+
|
|
60
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, member.network_id!, "delnet-node");
|
|
61
|
+
expect(minted.ok).toBe(true);
|
|
62
|
+
memberNodeToken = minted.token!;
|
|
63
|
+
|
|
64
|
+
const mod = await import("./server.js");
|
|
65
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
66
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
67
|
+
}, 30_000);
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
try { server?.stop(true); } catch {}
|
|
71
|
+
try { rmSync(PRIVATE_DIR, { recursive: true, force: true }); } catch {}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("DELETE /api/networks/:id — hub admin override for empty foreign networks", () => {
|
|
75
|
+
test("admin deletes an empty foreign network: 200, network + members + scoped tokens gone, audit has admin_override", async () => {
|
|
76
|
+
const netId = memberNetwork("empty");
|
|
77
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, netId, "leftover-node");
|
|
78
|
+
expect(minted.ok).toBe(true);
|
|
79
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
80
|
+
expect(rowCount("api_tokens", netId)).toBe(1);
|
|
81
|
+
|
|
82
|
+
const res = await del(netId, adminToken);
|
|
83
|
+
const body = await res.json() as any;
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(body.ok).toBe(true);
|
|
86
|
+
expect(body.admin_override).toBe(true);
|
|
87
|
+
|
|
88
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
|
|
89
|
+
expect(rowCount("network_members", netId)).toBe(0);
|
|
90
|
+
expect(rowCount("api_tokens", netId)).toBe(0);
|
|
91
|
+
// 被删网络的节点令牌不再能解析。
|
|
92
|
+
expect(auth.resolveToken(minted.token!)).toBeFalsy();
|
|
93
|
+
|
|
94
|
+
const audit = db.get<{ user_id: string; detail: string; network_id: string }>(
|
|
95
|
+
"SELECT user_id, detail, network_id FROM audit_log WHERE action = 'network_deleted' AND target_id = ?1", netId);
|
|
96
|
+
expect(audit).toBeTruthy();
|
|
97
|
+
expect(audit!.user_id).toBe(adminUserId);
|
|
98
|
+
const detail = JSON.parse(audit!.detail);
|
|
99
|
+
expect(detail.admin_override).toBe(true);
|
|
100
|
+
expect(detail.owner_id).toBe(memberUserId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("admin is refused with 409 and counts when the network has a node and a session", async () => {
|
|
104
|
+
const netId = memberNetwork("busy");
|
|
105
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
106
|
+
[`node-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
107
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`resume-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
108
|
+
|
|
109
|
+
const res = await del(netId, adminToken);
|
|
110
|
+
const body = await res.json() as any;
|
|
111
|
+
expect(res.status).toBe(409);
|
|
112
|
+
expect(body.ok).toBe(false);
|
|
113
|
+
expect(body.counts).toEqual({ nodes: 1, sessions: 1 });
|
|
114
|
+
expect(body.error).toContain("nodes=1");
|
|
115
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
116
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("admin is refused when the only content left is an uploaded file (disk index, not the DB)", async () => {
|
|
120
|
+
const netId = memberNetwork("files");
|
|
121
|
+
const indexDir = join(process.env.COMMHUB_UPLOADS_DIR!, ".index");
|
|
122
|
+
mkdirSync(indexDir, { recursive: true });
|
|
123
|
+
writeFileSync(join(indexDir, `f${stamp}.json`), JSON.stringify({ file_id: `f${stamp}`, network_id: netId }));
|
|
124
|
+
|
|
125
|
+
const res = await del(netId, adminToken);
|
|
126
|
+
const body = await res.json() as any;
|
|
127
|
+
expect(res.status).toBe(409);
|
|
128
|
+
expect(body.counts).toEqual({ files: 1 });
|
|
129
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("admin cannot delete the 'default' network even when it is empty", async () => {
|
|
133
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id) VALUES ('default', ?1, ?2)", [`legacy-default-${stamp}`, memberUserId]);
|
|
134
|
+
const res = await del("default", adminToken);
|
|
135
|
+
expect(res.status).toBe(409);
|
|
136
|
+
expect(((await res.json()) as any).error).toContain("default");
|
|
137
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = 'default'")).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a non-admin user cannot delete someone else's network", async () => {
|
|
141
|
+
const res = await del(adminNetworkId, memberToken);
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(((await res.json()) as any).error).toBe("not your network");
|
|
144
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", adminNetworkId)).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a node token is refused with user_token_required", async () => {
|
|
148
|
+
const netId = memberNetwork("ntok");
|
|
149
|
+
const res = await del(netId, memberNodeToken);
|
|
150
|
+
expect(res.status).toBe(403);
|
|
151
|
+
expect(((await res.json()) as any).error).toBe("user_token_required");
|
|
152
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("owner path is unchanged: owner deletes own network (even with a node row), sessions still block it", async () => {
|
|
156
|
+
const withNode = memberNetwork("owner-node");
|
|
157
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
158
|
+
[`owner-node-${stamp}`, `owner-node-${stamp}`, withNode]);
|
|
159
|
+
const ok = await del(withNode, memberToken);
|
|
160
|
+
expect(ok.status).toBe(200);
|
|
161
|
+
expect(await ok.json()).toEqual({ ok: true });
|
|
162
|
+
|
|
163
|
+
const withSession = memberNetwork("owner-session");
|
|
164
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`owner-resume-${stamp}`, `owner-s-${stamp}`, withSession]);
|
|
165
|
+
const blocked = await del(withSession, memberToken);
|
|
166
|
+
expect(blocked.status).toBe(400);
|
|
167
|
+
expect(((await blocked.json()) as any).error).toContain("active session");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("admin deleting its OWN network goes through the owner path (no admin_override)", async () => {
|
|
171
|
+
const created = auth.createNetwork(adminUserId, `admin-own-${stamp}`);
|
|
172
|
+
expect(created.ok).toBe(true);
|
|
173
|
+
const res = await del(created.network_id!, adminToken);
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
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
|
+
});
|
|
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
|
+
});
|