@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// 多用户账号与 Agent 权限 —— slice 3:剩下的面向 Agent 的路径。
|
|
2
|
+
//
|
|
3
|
+
// slice 1(agent-acl-http.test.ts)把默认作用域做成 fail-closed,并对核心路径逐条放行。
|
|
4
|
+
// 这里补:授权后 task_events / messages 的「只看自己往来」、改名广播不外泄 alias、
|
|
5
|
+
// 排程在建的人被收权后不再代派、用户名与后注册的 Agent alias 撞名、以及
|
|
6
|
+
// rules / skills / files / logs 对受限成员(即使授权了)仍然拒绝 —— 这是一个明确的产品决定:
|
|
7
|
+
// 授权 = 能看见、能对话;不等于能管理这个 Agent。
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { register, addNetworkMember } from "./auth.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { replaceAgentGrants } from "./agent-access.js";
|
|
16
|
+
import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-agent-acl3-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
const PW = "AclSlice3Passw0rd!";
|
|
22
|
+
let adminToken = "", adminId = "", adminName = "", NET = "";
|
|
23
|
+
let carolToken = "", carolId = "";
|
|
24
|
+
|
|
25
|
+
const X = { alias: "acl3-agent-x", node: "node_acl3_x" };
|
|
26
|
+
const Z = { alias: "acl3-agent-z", node: "node_acl3_z" };
|
|
27
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
28
|
+
|
|
29
|
+
async function get(token: string, path: string) {
|
|
30
|
+
const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
|
|
31
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
32
|
+
}
|
|
33
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
34
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...auth(token), "Content-Type": "application/json" }, body: payload === undefined ? undefined : JSON.stringify(payload) });
|
|
35
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
36
|
+
}
|
|
37
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
38
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { ...auth(token), "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
41
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
42
|
+
});
|
|
43
|
+
const raw = await res.text();
|
|
44
|
+
const data = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
45
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
46
|
+
const text = payload.result.content[0].text;
|
|
47
|
+
// 参数没过 zod 的调用根本没进到处理函数(也就没进到权限判定),当成测试写错了而不是「被拒」。
|
|
48
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
}
|
|
51
|
+
function seedAgent(a: { alias: string; node: string }) {
|
|
52
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [a.node, a.alias, NET, adminId]);
|
|
53
|
+
db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`, [`r_${a.node}`, a.alias, a.node, NET]);
|
|
54
|
+
}
|
|
55
|
+
function seedTask(id: string, from: string, to: string, content: string) {
|
|
56
|
+
db.run(`INSERT INTO tasks (task_id, from_name, to_name, status, content, network_id) VALUES (?1, ?2, ?3, 'replied', ?4, ?5)`, [id, from, to, content, NET]);
|
|
57
|
+
db.run(`INSERT INTO inbox (id, task_id, session_name, type, priority, content, from_session, network_id) VALUES (?1, ?2, ?3, 'task', 'normal', ?4, ?5, ?6)`, [`ib_${id}`, id, to, content, from, NET]);
|
|
58
|
+
db.run(`INSERT INTO task_events (task_id, to_status, actor, network_id) VALUES (?1, 'replied', 'test', ?2)`, [id, NET]);
|
|
59
|
+
}
|
|
60
|
+
async function openStream(path: string, token: string) {
|
|
61
|
+
const ctrl = new AbortController();
|
|
62
|
+
const res = await fetch(`${BASE}${path}`, { headers: auth(token), signal: ctrl.signal });
|
|
63
|
+
const reader = res.body!.getReader();
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
let buf = "";
|
|
66
|
+
let pending: ReturnType<typeof reader.read> | null = null;
|
|
67
|
+
const readFor = async (ms: number) => {
|
|
68
|
+
const until = Date.now() + ms;
|
|
69
|
+
while (Date.now() < until) {
|
|
70
|
+
pending ??= reader.read();
|
|
71
|
+
const chunk = await Promise.race([pending, new Promise<null>(r => setTimeout(() => r(null), until - Date.now()))]);
|
|
72
|
+
if (!chunk) break;
|
|
73
|
+
pending = null;
|
|
74
|
+
if (chunk.done) break;
|
|
75
|
+
buf += decoder.decode(chunk.value);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return { status: res.status, readFor, events: () => buf.split("\n").filter(l => l.startsWith("data:")).map(l => JSON.parse(l.slice(5))), close: () => ctrl.abort() };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
83
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
84
|
+
process.env.HOST = "127.0.0.1";
|
|
85
|
+
adminName = `acl3_admin_${Date.now()}`;
|
|
86
|
+
const admin = register(adminName, PW);
|
|
87
|
+
adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
|
|
88
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
89
|
+
seedAgent(X); seedAgent(Z);
|
|
90
|
+
const carol = register("acl3_carol", PW);
|
|
91
|
+
carolToken = carol.token!; carolId = carol.user!.user_id;
|
|
92
|
+
expect(addNetworkMember(NET, carolId, "member", adminId).ok).toBe(true);
|
|
93
|
+
expect(replaceAgentGrants({ networkId: NET, userId: carolId, grants: [{ node_id: X.node }], actorUserId: adminId }).ok).toBe(true);
|
|
94
|
+
seedTask("t3_admin_x", adminName, X.alias, "admin to X");
|
|
95
|
+
seedTask("t3_carol_x", "acl3_carol", X.alias, "carol to X");
|
|
96
|
+
seedTask("t3_x_carol", X.alias, "acl3_carol", "X to carol");
|
|
97
|
+
const mod: any = await import("./server.js");
|
|
98
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
99
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
100
|
+
}, 30_000);
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
try { hub?.stop?.(true); } catch {}
|
|
104
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("granted member: task_events and messages are limited to own traffic", () => {
|
|
108
|
+
test("task_events: own exchange with X visible, admin's task to X not", async () => {
|
|
109
|
+
const mine = await get(carolToken, `/api/task_events?task_id=t3_carol_x`);
|
|
110
|
+
expect(mine.body.events.length).toBe(1);
|
|
111
|
+
expect((await get(carolToken, `/api/task_events?task_id=t3_x_carol`)).body.events.length).toBe(1);
|
|
112
|
+
expect((await get(carolToken, `/api/task_events?task_id=t3_admin_x`)).body.events).toEqual([]);
|
|
113
|
+
const all = await get(carolToken, `/api/task_events?network_id=${NET}`);
|
|
114
|
+
expect(all.body.events.map((e: any) => e.task_id).sort()).toEqual(["t3_carol_x", "t3_x_carol"]);
|
|
115
|
+
});
|
|
116
|
+
test("messages (alias branch): own inbox rows with X only; admin's never", async () => {
|
|
117
|
+
const r = await get(carolToken, `/api/messages?network_id=${NET}&since=2000-01-01`);
|
|
118
|
+
expect(r.body.messages.map((m: any) => m.content).sort()).toEqual(["X to carol", "carol to X"].sort());
|
|
119
|
+
const x = await get(carolToken, `/api/messages?alias=${X.alias}&since=2000-01-01`);
|
|
120
|
+
expect(x.body.messages.map((m: any) => m.content)).toEqual(["carol to X"]);
|
|
121
|
+
expect(x.body.pending_count).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("decision: granted ≠ administer — rules / skills / files / logs / config stay refused", () => {
|
|
126
|
+
test("every read and write admin op on the GRANTED agent is refused", async () => {
|
|
127
|
+
for (const [name, args] of [
|
|
128
|
+
["read_node_rules_file", { alias: X.alias, network_id: NET }],
|
|
129
|
+
["write_node_rules_file", { alias: X.alias, content: "x", network_id: NET }],
|
|
130
|
+
["list_node_skills", { alias: X.alias, network_id: NET }],
|
|
131
|
+
["list_node_files", { alias: X.alias, network_id: NET }],
|
|
132
|
+
["tail_node_logs", { alias: X.alias, network_id: NET }],
|
|
133
|
+
["update_node_config", { node_id: X.node, base_revision: 0, patch: { model: "m" }, network_id: NET }],
|
|
134
|
+
["restart_node", { node_id: X.node, network_id: NET }],
|
|
135
|
+
] as const) {
|
|
136
|
+
expect((await tool(carolToken, name, args as any)).error).toBe("agent_access_restricted");
|
|
137
|
+
}
|
|
138
|
+
expect((await send(carolToken, "PUT", `/api/nodes/${X.node}/attrs?network_id=${NET}`, { display_name: "pwned" })).status).toBeGreaterThanOrEqual(403);
|
|
139
|
+
expect(db.get<{ display_name: string | null }>("SELECT display_name FROM nodes WHERE node_id = ?1", X.node)?.display_name ?? null).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("rename broadcast does not leak aliases to restricted members", () => {
|
|
144
|
+
test("renaming Z (not granted) sends nothing to carol; renaming X (granted) does", async () => {
|
|
145
|
+
const s = await openStream(`/events/acl3_carol?network_id=${NET}`, carolToken);
|
|
146
|
+
expect(s.status).toBe(200);
|
|
147
|
+
await s.readFor(100);
|
|
148
|
+
const renameTo = async (oldAlias: string, newAlias: string) => {
|
|
149
|
+
const p = await send(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: oldAlias, new_alias: newAlias });
|
|
150
|
+
expect(p.body.ok).toBe(true);
|
|
151
|
+
expect((await send(adminToken, "POST", "/api/node-rename/commit", { txn_id: p.body.txn_id })).body.ok).toBe(true);
|
|
152
|
+
};
|
|
153
|
+
await renameTo(Z.alias, "acl3-agent-z2");
|
|
154
|
+
await renameTo(X.alias, "acl3-agent-x2");
|
|
155
|
+
await s.readFor(400);
|
|
156
|
+
s.close();
|
|
157
|
+
const renamed = s.events().filter(e => e.type === "node.renamed").map(e => e.data.new_alias);
|
|
158
|
+
expect(renamed).toEqual(["acl3-agent-x2"]);
|
|
159
|
+
// 改名后授权(按 node_id)仍然有效。
|
|
160
|
+
expect((await get(carolToken, `/api/status?network_id=${NET}`)).body.sessions.map((r: any) => r.alias)).toEqual(["acl3-agent-x2"]);
|
|
161
|
+
await renameTo("acl3-agent-z2", Z.alias);
|
|
162
|
+
await renameTo("acl3-agent-x2", X.alias);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("schedules created before a restriction stop dispatching", () => {
|
|
167
|
+
function schedule(createdBy: string, target: { alias: string; node: string }) {
|
|
168
|
+
const id = `sched_acl3_${createdBy}_${target.node}`;
|
|
169
|
+
db.run(
|
|
170
|
+
`INSERT INTO scheduled_tasks (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, schedule_type, schedule_json, next_run_at)
|
|
171
|
+
VALUES (?1, ?2, ?3, 'n', ?4, ?5, 'scheduled work', 'interval', '{"type":"interval","minutes":60}', datetime('now'))`,
|
|
172
|
+
[id, NET, createdBy, target.node, target.alias],
|
|
173
|
+
);
|
|
174
|
+
return db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", id);
|
|
175
|
+
}
|
|
176
|
+
test("carol (restricted) → Z (not granted): run fails creator_access_revoked, no task created", () => {
|
|
177
|
+
const row = schedule(carolId, Z);
|
|
178
|
+
const r = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
|
|
179
|
+
expect(r.status).toBe("failed");
|
|
180
|
+
expect(r.taskId).toBeUndefined();
|
|
181
|
+
expect(db.get<{ error_code: string }>("SELECT error_code FROM scheduled_task_runs WHERE run_id = ?1", r.runId)?.error_code).toBe("creator_access_revoked");
|
|
182
|
+
});
|
|
183
|
+
test("carol → X (granted, can_message): dispatches", () => {
|
|
184
|
+
const r = dispatchScheduledOccurrence(schedule(carolId, X), new Date(Date.now() + 1000).toISOString(), false);
|
|
185
|
+
expect(r.taskId).toBeTruthy();
|
|
186
|
+
});
|
|
187
|
+
test("owner-created schedules are unaffected", () => {
|
|
188
|
+
const r = dispatchScheduledOccurrence(schedule(adminId, Z), new Date(Date.now() + 2000).toISOString(), false);
|
|
189
|
+
expect(r.taskId).toBeTruthy();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("username collides with an agent alias registered later", () => {
|
|
194
|
+
test("carol's own channel and own traffic close for that network", async () => {
|
|
195
|
+
expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks.length).toBeGreaterThan(0);
|
|
196
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES ('r_collide', 'acl3_carol', 'idle', ?1, datetime('now'), datetime('now'))`, [NET]);
|
|
197
|
+
const s = await fetch(`${BASE}/events/acl3_carol?network_id=${NET}`, { headers: auth(carolToken) });
|
|
198
|
+
expect(s.status).toBe(403);
|
|
199
|
+
await s.body?.cancel();
|
|
200
|
+
expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks).toEqual([]);
|
|
201
|
+
expect((await get(carolToken, `/api/messages?network_id=${NET}&since=2000-01-01`)).body.messages).toEqual([]);
|
|
202
|
+
db.run("DELETE FROM sessions WHERE resume_id = 'r_collide'");
|
|
203
|
+
expect((await get(carolToken, `/api/tasks?network_id=${NET}`)).body.tasks.length).toBeGreaterThan(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("human ↔ human DM (/api/dm): restricted members can talk to people", () => {
|
|
208
|
+
let daveToken = "", daveId = "";
|
|
209
|
+
test("carol (restricted) DMs dave; dave sees it in thread + threads + user inbox and replies", async () => {
|
|
210
|
+
const dave = register("acl3_dave", PW);
|
|
211
|
+
daveToken = dave.token!; daveId = dave.user!.user_id;
|
|
212
|
+
expect(addNetworkMember(NET, daveId, "member", adminId).ok).toBe(true);
|
|
213
|
+
const sent = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "hi dave", client_request_id: "c1" });
|
|
214
|
+
expect(sent.status).toBe(200);
|
|
215
|
+
expect(sent.body.message.sender_user_id).toBe(carolId);
|
|
216
|
+
// 重试同一个气泡不产生第二条。
|
|
217
|
+
expect((await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "hi dave", client_request_id: "c1" })).body.message.message_id).toBe(sent.body.message.message_id);
|
|
218
|
+
const threads = await get(daveToken, `/api/dm/threads?network_id=${NET}`);
|
|
219
|
+
expect(threads.body.threads).toEqual([expect.objectContaining({ other_user_id: carolId, unread: 1 })]);
|
|
220
|
+
expect((await get(daveToken, `/api/messages?scope=user&network_id=${NET}`)).body.messages.map((m: any) => m.content)).toContain("hi dave");
|
|
221
|
+
expect((await send(daveToken, "POST", "/api/dm", { network_id: NET, to_username: "acl3_carol", message: "hi carol" })).status).toBe(200);
|
|
222
|
+
const thread = await get(carolToken, `/api/dm?network_id=${NET}&with=${daveId}`);
|
|
223
|
+
expect(thread.body.messages.map((m: any) => [m.direction, m.content])).toEqual([["in", "hi carol"], ["out", "hi dave"]]);
|
|
224
|
+
// 私信不进按 Agent 的未读(发信人不是 Agent)。
|
|
225
|
+
const inbox = await get(daveToken, `/api/messages?scope=user&network_id=${NET}`);
|
|
226
|
+
expect(inbox.body.unread_by_agent["acl3_carol"]).toBeUndefined();
|
|
227
|
+
const daveThread = await get(daveToken, `/api/dm?network_id=${NET}&with=${carolId}`);
|
|
228
|
+
expect(daveThread.body.messages.map((m: any) => m.direction)).toEqual(["out", "in"]);
|
|
229
|
+
});
|
|
230
|
+
test("sender identity comes from the token, not from the body", async () => {
|
|
231
|
+
await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "who am i", from: adminName, sender_user_id: adminId });
|
|
232
|
+
const row = db.get<{ sender_user_id: string; from_session: string }>("SELECT sender_user_id, from_session FROM user_inbox WHERE content = 'who am i'");
|
|
233
|
+
expect(row).toEqual({ sender_user_id: carolId, from_session: "acl3_carol" });
|
|
234
|
+
});
|
|
235
|
+
test("a third party cannot read someone else's thread", async () => {
|
|
236
|
+
const eve = register("acl3_eve", PW);
|
|
237
|
+
expect(addNetworkMember(NET, eve.user!.user_id, "member", adminId).ok).toBe(true);
|
|
238
|
+
// eve 查「她和 dave」的会话:空(carol↔dave 的不会出现)。
|
|
239
|
+
expect((await get(eve.token!, `/api/dm?network_id=${NET}&with=${daveId}`)).body.messages).toEqual([]);
|
|
240
|
+
expect((await get(eve.token!, `/api/dm/threads?network_id=${NET}`)).body.threads).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
test("non-members and outsiders are refused; unknown and outside users look the same", async () => {
|
|
243
|
+
const out = register("acl3_out", PW);
|
|
244
|
+
expect((await send(out.token!, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "x" })).status).toBe(403);
|
|
245
|
+
expect((await get(out.token!, `/api/dm/threads?network_id=${NET}`)).status).toBe(403);
|
|
246
|
+
const a = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: out.user!.user_id, message: "x" });
|
|
247
|
+
const b = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: "u_nobody", message: "x" });
|
|
248
|
+
expect(a).toEqual(b);
|
|
249
|
+
expect(a.status).toBe(404);
|
|
250
|
+
});
|
|
251
|
+
test("a restricted member cannot smuggle a file they cannot see into a DM", async () => {
|
|
252
|
+
const form = new FormData();
|
|
253
|
+
form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "a.bin");
|
|
254
|
+
const up = await fetch(`${BASE}/api/upload?network_id=${NET}`, { method: "POST", body: form, headers: auth(adminToken) });
|
|
255
|
+
const fid = (await up.json()).file_id;
|
|
256
|
+
const r = await send(carolToken, "POST", "/api/dm", { network_id: NET, to_user_id: daveId, message: "f", attachments: [{ type: "file", file_id: fid }] });
|
|
257
|
+
expect(r.status).toBe(403);
|
|
258
|
+
expect(r.body.error).toBe("attachment_not_accessible");
|
|
259
|
+
// 管理员(不受限)可以附带本网络文件发给 carol;carol 因此能下载它。
|
|
260
|
+
expect((await send(adminToken, "POST", "/api/dm", { network_id: NET, to_user_id: carolId, message: "file for you", attachments: [{ type: "file", file_id: fid }] })).status).toBe(200);
|
|
261
|
+
expect((await fetch(`${BASE}/api/files/${fid}`, { headers: auth(carolToken) })).status).toBe(200);
|
|
262
|
+
});
|
|
263
|
+
test("node tokens cannot use the DM API", async () => {
|
|
264
|
+
const r = await fetch(`${BASE}/api/dm/threads?network_id=${NET}`, { headers: { Authorization: "Bearer ntok_x" } });
|
|
265
|
+
expect(r.status).toBeGreaterThanOrEqual(401);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Agent 分组的性能守卫(RFC-038 §8):visibleAgents() 每个请求多做一次「组授权 → 组成员」展开,
|
|
2
|
+
// 这里量它的额外开销,并用 EXPLAIN QUERY PLAN 钉住展开查询走索引(不是全表扫)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
|
|
5
|
+
// baseline —— bob 直接授权这 300 个节点(没有组);
|
|
6
|
+
// groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
|
|
7
|
+
// 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价(交替量 15 轮,取每轮差值的中位数)。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
|
|
8
|
+
// 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { visibleAgents } from "./agent-access.js";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-perf-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
const PW = "GroupPerfPassw0rd!x";
|
|
22
|
+
let NET = "";
|
|
23
|
+
let adminToken = "";
|
|
24
|
+
let adminId = "";
|
|
25
|
+
let alice = { token: "", id: "" };
|
|
26
|
+
let bob = { token: "", id: "" };
|
|
27
|
+
const NODES = 300;
|
|
28
|
+
const GROUPS = 50;
|
|
29
|
+
const PER_GROUP = NODES / GROUPS;
|
|
30
|
+
const BOUND_MS = 5;
|
|
31
|
+
const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
|
|
32
|
+
// CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
|
|
33
|
+
// 15 次取中位数足够稳;性能用例另给显式超时。
|
|
34
|
+
const WARMUP = 3;
|
|
35
|
+
const ITER = 15;
|
|
36
|
+
const PERF_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
|
|
39
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
40
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
|
|
41
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
42
|
+
}
|
|
43
|
+
const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
|
|
44
|
+
/**
|
|
45
|
+
* 交替计时:每一轮先量 A 再量 B,取「B − A」的中位数(再附上两边各自的中位数给人看)。
|
|
46
|
+
* 不能 A 连量 N 次再 B 连量 N 次:test638 在同一台机器上并发跑两份 aggregate,负载随时间变,
|
|
47
|
+
* 两段分开量时差值被负载漂移主导(--cpus=1 实测差出 26–33 ms,真实差值 < 1 ms)。
|
|
48
|
+
*/
|
|
49
|
+
function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: number; delta: number } {
|
|
50
|
+
for (let i = 0; i < WARMUP; i++) { a(); b(); }
|
|
51
|
+
const as: number[] = [], bs: number[] = [], ds: number[] = [];
|
|
52
|
+
for (let i = 0; i < n; i++) {
|
|
53
|
+
let t = performance.now(); a(); const ta = performance.now() - t;
|
|
54
|
+
t = performance.now(); b(); const tb = performance.now() - t;
|
|
55
|
+
as.push(ta); bs.push(tb); ds.push(tb - ta);
|
|
56
|
+
}
|
|
57
|
+
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
58
|
+
}
|
|
59
|
+
async function pairedDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, n: number): Promise<{ a: number; b: number; delta: number }> {
|
|
60
|
+
for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
|
|
61
|
+
const as: number[] = [], bs: number[] = [], ds: number[] = [];
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
let t = performance.now(); await a(); const ta = performance.now() - t;
|
|
64
|
+
t = performance.now(); await b(); const tb = performance.now() - t;
|
|
65
|
+
as.push(ta); bs.push(tb); ds.push(tb - ta);
|
|
66
|
+
}
|
|
67
|
+
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
72
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
73
|
+
process.env.HOST = "127.0.0.1";
|
|
74
|
+
const admin = register(`perf_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
75
|
+
expect(admin.ok).toBe(true);
|
|
76
|
+
adminToken = admin.token!;
|
|
77
|
+
adminId = admin.user!.user_id;
|
|
78
|
+
NET = admin.network_id!;
|
|
79
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
for (let i = 0; i < NODES; i++) {
|
|
82
|
+
const node = `node_perf_${i}`;
|
|
83
|
+
const alias = `perf-agent-${i}`;
|
|
84
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`, [node, alias, NET, adminId]);
|
|
85
|
+
db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`, [`resume_${node}`, alias, node, NET]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const mod: any = await import("./server.js");
|
|
89
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
91
|
+
|
|
92
|
+
const mk = async (username: string) => {
|
|
93
|
+
const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
|
|
94
|
+
expect(r.status).toBe(200);
|
|
95
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
96
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
97
|
+
};
|
|
98
|
+
alice = await mk(`perf_alice_${Date.now()}`);
|
|
99
|
+
bob = await mk(`perf_bob_${Date.now()}`);
|
|
100
|
+
|
|
101
|
+
const groupIds: string[] = [];
|
|
102
|
+
for (let g = 0; g < GROUPS; g++) {
|
|
103
|
+
const nodeIds = Array.from({ length: PER_GROUP }, (_, k) => `node_perf_${g * PER_GROUP + k}`);
|
|
104
|
+
const r = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: `perf-group-${g}`, node_ids: nodeIds });
|
|
105
|
+
expect(r.status).toBe(200);
|
|
106
|
+
groupIds.push(r.body.group.group_id);
|
|
107
|
+
}
|
|
108
|
+
expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${alice.id}/agent-grants`, { group_grants: groupIds })).status).toBe(200);
|
|
109
|
+
expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${bob.id}/agent-grants`, { grants: Array.from({ length: NODES }, (_, i) => `node_perf_${i}`) })).status).toBe(200);
|
|
110
|
+
}, 60_000);
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
try { hub?.stop?.(true); } catch {}
|
|
114
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
118
|
+
test("两人可见集合相同(比较才公平)", () => {
|
|
119
|
+
const a = visibleAgents(alice.id, NET);
|
|
120
|
+
const b = visibleAgents(bob.id, NET);
|
|
121
|
+
expect(a.nodeIds.length).toBe(NODES);
|
|
122
|
+
expect([...a.nodeIds].sort()).toEqual([...b.nodeIds].sort());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
|
|
126
|
+
const r = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, ITER);
|
|
127
|
+
console.log(`[perf] visibleAgents median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
128
|
+
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
129
|
+
}, PERF_TIMEOUT_MS);
|
|
130
|
+
|
|
131
|
+
test(`受限成员 GET /api/status:组授权比等量直接授权多出 ≤ max(${BOUND_MS} ms, 基线 15%)`, async () => {
|
|
132
|
+
const hit = async (token: string) => {
|
|
133
|
+
const r = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: json(token) });
|
|
134
|
+
const body = await r.json() as any;
|
|
135
|
+
if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
|
|
136
|
+
};
|
|
137
|
+
const r = await pairedDeltaAsync(() => hit(bob.token), () => hit(alice.token), ITER);
|
|
138
|
+
console.log(`[perf] GET /api/status median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
139
|
+
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
140
|
+
}, PERF_TIMEOUT_MS);
|
|
141
|
+
|
|
142
|
+
test("EXPLAIN QUERY PLAN:组展开走索引,不全表扫描", () => {
|
|
143
|
+
const plan = db.all<{ detail: string }>(
|
|
144
|
+
`EXPLAIN QUERY PLAN
|
|
145
|
+
SELECT m.node_id, g.can_message
|
|
146
|
+
FROM network_member_group_grants g
|
|
147
|
+
JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
|
|
148
|
+
JOIN agent_group_members m ON m.group_id = g.group_id
|
|
149
|
+
JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
|
|
150
|
+
WHERE g.network_id = ?1 AND g.user_id = ?2`,
|
|
151
|
+
NET, alice.id,
|
|
152
|
+
).map((r) => r.detail);
|
|
153
|
+
console.log(`[perf] plan:\n ${plan.join("\n ")}`);
|
|
154
|
+
// 四张表都是 SEARCH(按主键 / 索引定位),没有一张是 SCAN。
|
|
155
|
+
expect(plan.length).toBeGreaterThanOrEqual(4);
|
|
156
|
+
expect(plan.filter((d) => /^SCAN\b/.test(d))).toEqual([]);
|
|
157
|
+
expect(plan.every((d) => /^SEARCH\b/.test(d) && /(PRIMARY KEY|INDEX)/.test(d))).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("EXPLAIN QUERY PLAN:按组找被授权成员(改组成员时断流用)走 group_id 索引", () => {
|
|
161
|
+
const plan = db.all<{ detail: string }>(
|
|
162
|
+
"EXPLAIN QUERY PLAN SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", "x",
|
|
163
|
+
).map((r) => r.detail);
|
|
164
|
+
console.log(`[perf] plan (usersGrantedGroup): ${plan.join(" | ")}`);
|
|
165
|
+
expect(plan.some((d) => /SEARCH .*idx_group_grants_group/.test(d))).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { parseAliasFilter } from "./alias-filter";
|
|
3
|
+
|
|
4
|
+
test("no filter given means no filtering, not an empty match", () => {
|
|
5
|
+
for (const raw of [undefined, null, "", " ", ",", " , , "]) {
|
|
6
|
+
const f = parseAliasFilter(raw as any);
|
|
7
|
+
expect(f.aliases).toEqual([]);
|
|
8
|
+
expect(f.sql).toBe("");
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a single alias produces one placeholder", () => {
|
|
13
|
+
const f = parseAliasFilter("节点甲");
|
|
14
|
+
expect(f.aliases).toEqual(["节点甲"]);
|
|
15
|
+
expect(f.sql).toBe(" AND alias IN (?)");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("several aliases keep their order and count", () => {
|
|
19
|
+
const f = parseAliasFilter("A站内容,A站内容牛,hub");
|
|
20
|
+
expect(f.aliases).toEqual(["A站内容", "A站内容牛", "hub"]);
|
|
21
|
+
expect(f.sql).toBe(" AND alias IN (?,?,?)");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("surrounding whitespace is trimmed", () => {
|
|
25
|
+
expect(parseAliasFilter(" a , b ").aliases).toEqual(["a", "b"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The point of the module. A trailing comma must not become `alias = ''`,
|
|
29
|
+
// which matches nothing and reads exactly like "those nodes do not exist".
|
|
30
|
+
test("blank entries are dropped, never turned into a match-nothing term", () => {
|
|
31
|
+
const f = parseAliasFilter("a,,b,");
|
|
32
|
+
expect(f.aliases).toEqual(["a", "b"]);
|
|
33
|
+
expect(f.sql).toBe(" AND alias IN (?,?)");
|
|
34
|
+
expect(f.aliases).not.toContain("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a filter of only commas is the same as no filter — it must not silently match zero rows", () => {
|
|
38
|
+
const f = parseAliasFilter(",,,");
|
|
39
|
+
expect(f.sql).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("placeholder count always equals alias count, so params can never misalign", () => {
|
|
43
|
+
for (const raw of ["a", "a,b", "a,,b", " a , b , c ", ",x,"]) {
|
|
44
|
+
const f = parseAliasFilter(raw);
|
|
45
|
+
expect((f.sql.match(/\?/g) ?? []).length).toBe(f.aliases.length);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("aliases are passed through verbatim — no globbing, no case folding", () => {
|
|
50
|
+
const f = parseAliasFilter("A站内容,a站内容");
|
|
51
|
+
expect(f.aliases).toEqual(["A站内容", "a站内容"]);
|
|
52
|
+
expect(f.sql).not.toContain("LIKE");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Wiring: the tool must actually use this module, and must say what its
|
|
56
|
+
// `summary` counted — a caller who asked about three aliases and gets back
|
|
57
|
+
// "idle: 96" can easily read the 96 as being about their three.
|
|
58
|
+
import { readFileSync } from "fs";
|
|
59
|
+
import { join } from "path";
|
|
60
|
+
|
|
61
|
+
test("get_all_status uses parseAliasFilter and declares what summary counted", () => {
|
|
62
|
+
const source = readFileSync(join(import.meta.dir, "tools.ts"), "utf8");
|
|
63
|
+
const a = source.indexOf('"get_all_status"');
|
|
64
|
+
expect(a).toBeGreaterThan(-1);
|
|
65
|
+
const body = source.slice(a, source.indexOf('server.tool(', a + 10));
|
|
66
|
+
expect(body).toContain("filter_alias");
|
|
67
|
+
expect(body).toContain("parseAliasFilter(filter_alias)");
|
|
68
|
+
expect(body).toContain("summary_scope");
|
|
69
|
+
expect(body).toContain("sessions_returned");
|
|
70
|
+
// The alias list must go through parameters, never be interpolated.
|
|
71
|
+
expect(body).not.toMatch(/alias IN \(\$\{/);
|
|
72
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Parse the `filter_alias` argument of `get_all_status` into an exact-match
|
|
2
|
+
// IN list.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists at all: on a 222-session hub the unfiltered response is about
|
|
5
|
+
// 259 KB — past what an MCP client accepts in one result. A caller who wanted
|
|
6
|
+
// the status of three specific nodes could not get it from the tool, and had to
|
|
7
|
+
// go around it to the REST API. The filter is the fix; this module is the part
|
|
8
|
+
// of it worth pinning, because the interesting behaviour is what happens to the
|
|
9
|
+
// inputs that are not a clean alias.
|
|
10
|
+
//
|
|
11
|
+
// Blank entries are DROPPED rather than matched. A trailing comma would
|
|
12
|
+
// otherwise produce `alias = ''`, which matches no row — and "no rows" reads
|
|
13
|
+
// exactly like "those nodes do not exist". The failure and the true answer
|
|
14
|
+
// would be indistinguishable to the caller, which is the thing to avoid.
|
|
15
|
+
|
|
16
|
+
export interface AliasFilter {
|
|
17
|
+
/** Exact aliases to match. Empty means "no alias filtering". */
|
|
18
|
+
aliases: string[];
|
|
19
|
+
/** SQL fragment to append, or "" when there is nothing to filter on. */
|
|
20
|
+
sql: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseAliasFilter(raw: string | undefined | null): AliasFilter {
|
|
24
|
+
const aliases = (raw ?? "")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map(a => a.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return {
|
|
29
|
+
aliases,
|
|
30
|
+
sql: aliases.length > 0 ? ` AND alias IN (${aliases.map(() => "?").join(",")})` : "",
|
|
31
|
+
};
|
|
32
|
+
}
|