@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.73
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 +2 -2
- package/bin/commhub.ts +82 -9
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +51 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +26 -9
- package/src/agent-acl-http.test.ts +9 -1
- package/src/agent-groups-perf.test.ts +23 -0
- package/src/auth.ts +22 -6
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +17 -1
- package/src/db-adapter.ts +78 -5
- package/src/db.ts +7 -1
- package/src/member-presence-http.test.ts +206 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/push.ts +80 -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.ts +102 -20
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +22 -9
- package/src/server.ts +17 -3
- package/src/side-thread-command-transport.test.ts +12 -2
- package/src/side-thread.ts +4 -0
- package/src/skillhub-http.test.ts +16 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/tools.ts +13 -10
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
|
|
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
|
|
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/bin/commhub.ts
CHANGED
|
@@ -12,19 +12,56 @@
|
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// RFC-039 S5 — offline SQLite → PostgreSQL copy. Handled before any server
|
|
16
|
+
// option parsing: it never starts a Hub and never opens the default database.
|
|
17
|
+
if (args[0] === "migrate-to-pg") {
|
|
18
|
+
const flag = (name: string) => args.includes(name);
|
|
19
|
+
const value = (name: string) => { const i = args.indexOf(name); return i > 0 ? args[i + 1] : undefined; };
|
|
20
|
+
const MIGRATE_VALUE_FLAGS = new Set(["--from", "--to"]);
|
|
21
|
+
const MIGRATE_FLAGS = new Set(["--dry-run", "--i-know-this-is-a-copy", "--help", "-h"]);
|
|
22
|
+
for (let i = 1; i < args.length; i++) {
|
|
23
|
+
if (MIGRATE_VALUE_FLAGS.has(args[i])) { i++; continue; }
|
|
24
|
+
if (!MIGRATE_FLAGS.has(args[i])) {
|
|
25
|
+
console.error(`commhub-server migrate-to-pg: unknown argument ${args[i]}\nRun "commhub-server migrate-to-pg --help" for usage.`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (flag("--help") || flag("-h") || !value("--from") || !value("--to")) {
|
|
23
30
|
console.log(`
|
|
31
|
+
Usage:
|
|
32
|
+
commhub-server migrate-to-pg --from <sqlite file> --to <postgres url> [--dry-run] [--i-know-this-is-a-copy]
|
|
33
|
+
|
|
34
|
+
Copies a stopped Hub's SQLite database into an EMPTY PostgreSQL database, in one
|
|
35
|
+
transaction, verifying per-table row counts and content hashes before COMMIT.
|
|
36
|
+
Experimental (RFC-039); available from commhub-server 0.9.0-preview.73.
|
|
37
|
+
Files under the uploads directory are not copied.
|
|
38
|
+
|
|
39
|
+
--dry-run do everything, then roll back and leave the target empty
|
|
40
|
+
--i-know-this-is-a-copy allow a source under ~/.commhub (refused by default)
|
|
41
|
+
`);
|
|
42
|
+
process.exit(flag("--help") || flag("-h") ? 0 : 2);
|
|
43
|
+
}
|
|
44
|
+
const { migrateSqliteToPg, MigrateRefused } = await import("../src/migrate-sqlite-to-pg.ts");
|
|
45
|
+
try {
|
|
46
|
+
await migrateSqliteToPg({
|
|
47
|
+
from: value("--from")!,
|
|
48
|
+
to: value("--to")!,
|
|
49
|
+
dryRun: flag("--dry-run"),
|
|
50
|
+
allowCommhubCopy: flag("--i-know-this-is-a-copy"),
|
|
51
|
+
});
|
|
52
|
+
process.exit(0);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(error instanceof MigrateRefused ? error.message : `[migrate-to-pg] FAILED, rolled back: ${(error as Error)?.message ?? error}`);
|
|
55
|
+
process.exit(error instanceof MigrateRefused ? 2 : 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HELP = `
|
|
24
60
|
CommHub MCP Server — AI Agent 通信中枢
|
|
25
61
|
|
|
26
62
|
Usage:
|
|
27
63
|
commhub-server [options]
|
|
64
|
+
commhub-server migrate-to-pg --help Copy a stopped Hub's SQLite database into PostgreSQL (experimental)
|
|
28
65
|
|
|
29
66
|
Options:
|
|
30
67
|
--port, -p <port> Port to listen on (default: 9200, env: PORT)
|
|
@@ -34,6 +71,7 @@ Options:
|
|
|
34
71
|
--cors <origins> CORS origins, comma-separated (env: COMMHUB_CORS_ORIGINS)
|
|
35
72
|
--dev-open Explicit unauthenticated local development mode
|
|
36
73
|
--help, -h Show this help
|
|
74
|
+
--version, -v Print the version and exit
|
|
37
75
|
|
|
38
76
|
Environment Variables:
|
|
39
77
|
PORT Server port (default: 9200)
|
|
@@ -49,8 +87,43 @@ Examples:
|
|
|
49
87
|
commhub-server --port 9200 --token my-secret-token
|
|
50
88
|
commhub-server --port 9200 --dev-open
|
|
51
89
|
PORT=9200 COMMHUB_AUTH_TOKEN=secret commhub-server
|
|
52
|
-
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
// Anything this bin does not recognise is a usage error: exit 2 and never
|
|
93
|
+
// start a Hub. Before this, unknown words and flags were ignored and a Hub
|
|
94
|
+
// started on the defaults — `commhub-server <some-subcommand>` on a version
|
|
95
|
+
// without that subcommand opened ~/.commhub/commhub.db on :9200.
|
|
96
|
+
function usageError(message: string): never {
|
|
97
|
+
console.error(`commhub-server: ${message}\nRun "commhub-server --help" for usage.`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const VALUE_FLAGS: Record<string, string> = {
|
|
102
|
+
"--port": "PORT", "-p": "PORT",
|
|
103
|
+
"--host": "HOST",
|
|
104
|
+
"--token": "COMMHUB_AUTH_TOKEN", "-t": "COMMHUB_AUTH_TOKEN",
|
|
105
|
+
"--db": "COMMHUB_DB",
|
|
106
|
+
"--cors": "COMMHUB_CORS_ORIGINS",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < args.length; i++) {
|
|
110
|
+
const arg = args[i];
|
|
111
|
+
if (Object.hasOwn(VALUE_FLAGS, arg)) {
|
|
112
|
+
const value = args[i + 1];
|
|
113
|
+
if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`);
|
|
114
|
+
process.env[VALUE_FLAGS[arg]] = value;
|
|
115
|
+
i++;
|
|
116
|
+
} else if (arg === "--dev-open") {
|
|
117
|
+
process.env.COMMHUB_DEV_OPEN = "1";
|
|
118
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
119
|
+
console.log(HELP);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
122
|
+
const pkg = (await import("../package.json", { with: { type: "json" } })).default as { version: string };
|
|
123
|
+
console.log(pkg.version);
|
|
53
124
|
process.exit(0);
|
|
125
|
+
} else {
|
|
126
|
+
usageError(arg.startsWith("-") ? `unknown option ${arg}` : `unknown command ${arg}`);
|
|
54
127
|
}
|
|
55
128
|
}
|
|
56
129
|
|
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.73",
|
|
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
|
+
});
|
package/src/agent-access.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
grantAliases = [grant.alias];
|
|
113
130
|
}
|
|
114
131
|
for (const alias of grantAliases) {
|
|
115
132
|
aliases.add(alias);
|
|
@@ -255,7 +255,7 @@ describe("humans: restricted members can still see and DM other humans", () => {
|
|
|
255
255
|
const r = await get(aliceToken, `/api/networks/${NET}/humans`);
|
|
256
256
|
expect(r.status).toBe(200);
|
|
257
257
|
expect(r.body.humans.map((h: any) => h.user_id).sort()).toEqual([adminId, aliceId, bobId].sort());
|
|
258
|
-
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "user_id", "username"]);
|
|
258
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
259
259
|
// 非成员拿不到。
|
|
260
260
|
const outsider = register(`acl_out_${Date.now()}`, PW);
|
|
261
261
|
expect((await get(outsider.token!, `/api/networks/${NET}/humans`)).status).toBe(403);
|
|
@@ -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.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
+
}
|