@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.69

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.
@@ -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
+ });
package/src/auth.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
6
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
+ import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js";
7
8
 
8
9
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
9
10
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -60,7 +61,7 @@ function validatePasswordStrength(password: string, label = "password"): string
60
61
  return null;
61
62
  }
62
63
 
63
- export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
64
+ export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean } = {}): AuthResult {
64
65
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
65
66
  if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
66
67
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
@@ -120,6 +121,16 @@ export function register(username: string, password: string, email?: string, dis
120
121
  [networkId, userId]
121
122
  );
122
123
 
124
+ // 管理员代建账号(POST /api/admin/users)时不给管理员发这个用户的令牌:
125
+ // 令牌只该由用户本人登录时拿到。
126
+ if (opts.issueTokens === false) {
127
+ return {
128
+ ok: true,
129
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
130
+ network_id: networkId,
131
+ };
132
+ }
133
+
123
134
  // User token (utok_) — not bound to network, for CLI/Dashboard login
124
135
  const userToken = generateUserToken();
125
136
  const userTokenId = generateId("tok");
@@ -225,6 +236,7 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
225
236
  // Verify user is a member of this network with write access
226
237
  const role = getUserNetworkRole(userId, networkId);
227
238
  if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
239
+ if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
228
240
  if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
229
241
  if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
230
242
  return { ok: false, error: "invalid_node_id" };
@@ -289,6 +301,11 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
289
301
 
290
302
  if (!row) return null;
291
303
 
304
+ // 多用户 Agent 权限:网络令牌(ntok_ / 邀请码令牌)的权限是「这个网络里的一切」,
305
+ // 受限成员在受限网络里不能持有它 —— 否则拿它就绕过了授权过滤。这里是唯一的解析入口,
306
+ // 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
307
+ if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return null;
308
+
292
309
  // Update last_used
293
310
  db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
294
311
 
@@ -382,6 +399,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
382
399
  const role = getUserNetworkRole(userId, networkId);
383
400
  if (!role) return { ok: false, error: "not a member of this network" };
384
401
  if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
402
+ if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
385
403
  }
386
404
  const token = generateToken();
387
405
  const tokenId = generateId("tok");
@@ -467,22 +485,55 @@ export function resetUserPassword(targetUsername: string, callerIsHubAdmin: bool
467
485
 
468
486
  export function getNetworkMembers(networkId: string) {
469
487
  return db.all<any>(
470
- `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
488
+ `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
489
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
490
+ (SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count
471
491
  FROM network_members nm JOIN users u ON nm.user_id = u.user_id
472
492
  WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
473
493
  networkId);
474
494
  }
475
495
 
496
+ /** 网络里的人类成员(给受限成员也能看的通讯录):只有身份字段,不含角色与授权。 */
497
+ export function getNetworkHumans(networkId: string) {
498
+ return db.all<{ user_id: string; username: string; display_name: string | null }>(
499
+ `SELECT u.user_id, u.username, u.display_name
500
+ FROM network_members nm JOIN users u ON nm.user_id = u.user_id
501
+ WHERE nm.network_id = ?1 ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username), u.user_id`,
502
+ networkId);
503
+ }
504
+
476
505
  export function getUserNetworkRole(userId: string, networkId: string): string | null {
477
506
  const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
478
507
  return row?.role || null;
479
508
  }
480
509
 
481
- export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
510
+ const MEMBER_ROLES = new Set(["admin", "member", "viewer"]);
511
+
512
+ /**
513
+ * 用户名与本网络某个 Agent 的 alias 相同时不能入网:用户的私人 SSE 频道、inbox、
514
+ * 任务往来都按「用户名」寻址,与 alias 撞名会让受限成员收到那个 Agent 的流量。
515
+ * 只在入网时查(管理员 / 邀请码持有者才触发),不放在公开的 /api/auth/register 上,
516
+ * 免得注册接口变成 alias 探测器。
517
+ */
518
+ export function usernameCollidesWithAgent(networkId: string, userId: string): boolean {
519
+ return !!db.get(
520
+ `SELECT 1 FROM users u WHERE u.user_id = ?2 AND (
521
+ EXISTS (SELECT 1 FROM sessions s WHERE s.network_id = ?1 AND s.alias = u.username)
522
+ OR EXISTS (SELECT 1 FROM nodes n WHERE n.network_id = ?1 AND n.alias = u.username))`,
523
+ networkId, userId,
524
+ );
525
+ }
526
+
527
+ // 新成员默认 agent_access='granted'(零 Agent 权限),owner/admin 角色不看这一列。
528
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted" } = {}): { ok: boolean; error?: string } {
529
+ if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
530
+ const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
531
+ if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
482
532
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
483
533
  if (existing) return { ok: false, error: "user already a member" };
484
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
485
- [networkId, userId, role, invitedBy || null]);
534
+ if (usernameCollidesWithAgent(networkId, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
535
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, ?5)",
536
+ [networkId, userId, role, invitedBy || null, agentAccess]);
486
537
  return { ok: true };
487
538
  }
488
539
 
@@ -498,6 +549,7 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
498
549
  if (!member) return { ok: false, error: "not a member" };
499
550
  if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
500
551
  db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
552
+ deleteAgentGrants(networkId, userId);
501
553
  return { ok: true };
502
554
  }
503
555
 
@@ -533,10 +585,13 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
533
585
  // Check not already member
534
586
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
535
587
  if (existing) return { ok: false, error: "already a member of this network" };
588
+ if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
536
589
  // Add member + increment used count
537
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
590
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, 'granted')",
538
591
  [invite.network_id, userId, invite.role, invite.created_by]);
539
592
  db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
593
+ // 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
594
+ if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
540
595
  // Auto-create a token for this network
541
596
  const token = generateToken();
542
597
  const tokenId = generateId("tok");
@@ -548,8 +603,98 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
548
603
  /** Get all networks a user is a member of (replaces owner-only query) */
549
604
  export function getUserAllNetworks(userId: string) {
550
605
  return db.all<any>(
551
- `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role
606
+ `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role,
607
+ -- 多用户 Agent 权限:客户端据此显示「还没有被分配任何 Agent,请联系管理员」。
608
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access
552
609
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
610
+ JOIN users u ON u.user_id = nm.user_id
553
611
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
554
612
  userId);
555
613
  }
614
+
615
+ // ══════════════════════════════════════
616
+ // 多用户账号:管理员建号
617
+ // ══════════════════════════════════════
618
+
619
+ export type AdminCreateUserInput = {
620
+ username?: unknown;
621
+ password?: unknown;
622
+ display_name?: unknown;
623
+ email?: unknown;
624
+ network_id?: unknown;
625
+ role?: unknown;
626
+ };
627
+
628
+ export type AdminCreateUserResult =
629
+ | { ok: true; user: AuthUser; network_id: string; membership: { network_id: string; role: string; agent_access: "granted" } | null }
630
+ | { ok: false; error: string; status: number };
631
+
632
+ /**
633
+ * 管理员(或某网络的 owner/admin,限于把人建进自己管的网络)代建账号。
634
+ * 走 register() 的全部规则(用户名、密码 ≥ 8 且不是弱密码、自动建个人网络),
635
+ * 但不签发令牌 —— 用户自己登录才拿令牌。
636
+ * 可选一步把新用户加进 network_id,默认 role=member、agent_access='granted'(零 Agent 权限)。
637
+ * 建号与入网在同一事务里:入网失败(如用户名与 Agent alias 撞名)时账号也不落库。
638
+ */
639
+ export function adminCreateUser(input: AdminCreateUserInput, actor: { userId: string; isHubAdmin: boolean }): AdminCreateUserResult {
640
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
641
+ const username = str(input.username);
642
+ const password = typeof input.password === "string" ? input.password : "";
643
+ const displayName = str(input.display_name) || undefined;
644
+ const email = str(input.email) || undefined;
645
+ const networkId = str(input.network_id) || null;
646
+ const role = str(input.role) || "member";
647
+
648
+ if (networkId) {
649
+ if (!db.get("SELECT 1 FROM networks WHERE network_id = ?1", networkId)) return { ok: false, error: "network_not_found", status: 404 };
650
+ if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role", status: 400 };
651
+ const actorRole = getUserNetworkRole(actor.userId, networkId);
652
+ if (!actor.isHubAdmin && actorRole !== "owner" && actorRole !== "admin") return { ok: false, error: "owner/admin required", status: 403 };
653
+ // 网络 admin 不能建出和自己平级的 admin;只有 owner / Hub 管理员可以。
654
+ if (role === "admin" && !actor.isHubAdmin && actorRole !== "owner") return { ok: false, error: "owner required to add admins", status: 403 };
655
+ } else if (!actor.isHubAdmin) {
656
+ return { ok: false, error: "admin required", status: 403 };
657
+ }
658
+ // 首个用户会被 register() 自动设成 Hub 管理员;代建路径上永远不会是首个用户(调用者本身就是用户)。
659
+ // 但仍显式拒绝,防止空库 + 旧令牌之类的边角把代建变成「造管理员」。
660
+ const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
661
+ if (!userCount || userCount.cnt === 0) return { ok: false, error: "bootstrap_via_register", status: 400 };
662
+
663
+ class Rollback extends Error { constructor(readonly result: AdminCreateUserResult) { super("rollback"); } }
664
+ try {
665
+ return db.transaction(() => {
666
+ const created = register(username, password, email, displayName, { issueTokens: false });
667
+ if (!created.ok || !created.user) throw new Rollback({ ok: false, error: created.error || "register_failed", status: 400 });
668
+ if (!networkId) return { ok: true as const, user: created.user, network_id: created.network_id!, membership: null };
669
+ const added = addNetworkMember(networkId, created.user.user_id, role, actor.userId);
670
+ if (!added.ok) throw new Rollback({ ok: false, error: added.error || "add_member_failed", status: added.error === "username_collides_with_agent_alias" ? 409 : 400 });
671
+ return {
672
+ ok: true as const,
673
+ user: created.user,
674
+ network_id: created.network_id!,
675
+ membership: { network_id: networkId, role, agent_access: "granted" as const },
676
+ };
677
+ });
678
+ } catch (error) {
679
+ if (error instanceof Rollback) return error.result;
680
+ throw error;
681
+ }
682
+ }
683
+
684
+ /** Hub 管理员的用户列表:每个用户带上他所在的网络与角色、agent_access。不含密码哈希。 */
685
+ export function listUsersWithMemberships() {
686
+ const users = db.all<{ user_id: string; username: string; display_name: string | null; email: string | null; role: string; created_at: string }>(
687
+ "SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at",
688
+ );
689
+ const memberships = db.all<{ user_id: string; network_id: string; network_name: string | null; role: string; agent_access: string | null }>(
690
+ `SELECT nm.user_id, nm.network_id, n.network_name, nm.role, nm.agent_access
691
+ FROM network_members nm JOIN networks n ON n.network_id = nm.network_id`,
692
+ );
693
+ const byUser = new Map<string, Array<{ network_id: string; network_name: string | null; role: string; agent_access: "all" | "granted" }>>();
694
+ for (const m of memberships) {
695
+ const list = byUser.get(m.user_id) ?? [];
696
+ list.push({ network_id: m.network_id, network_name: m.network_name, role: m.role, agent_access: m.agent_access === "all" ? "all" : "granted" });
697
+ byUser.set(m.user_id, list);
698
+ }
699
+ return users.map((u) => ({ ...u, networks: byUser.get(u.user_id) ?? [] }));
700
+ }
@@ -206,7 +206,8 @@ describe("/api/host-supervisors — default_workdir_root visibility", () => {
206
206
  if (!m.ok || !m.token) throw new Error("member register failed");
207
207
  memberTok = m.token;
208
208
  const memberId = login(`wd_member_${sfx}`, pw).user!.user_id;
209
- const add = addNetworkMember(restNet, memberId, "member");
209
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
210
+ const add = addNetworkMember(restNet, memberId, "member", undefined, { agentAccess: "all" });
210
211
  if (!add.ok) throw new Error("add member failed: " + add.error);
211
212
  const { bootServer } = await import("./server.js");
212
213
  server = bootServer({ port: 0, hostname: "127.0.0.1" });
package/src/db.ts CHANGED
@@ -818,6 +818,10 @@ function migrateUserInboxNetworkIdNotNull() {
818
818
  migrateUserInboxNetworkIdNotNull();
819
819
  // 迁移会 drop 掉旧表上的 idx_user_inbox_user_acked → 重建(IF NOT EXISTS;未迁移时 no-op)。
820
820
  db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked ON user_inbox(user_id, acked, created_at);`);
821
+ // 多用户:人与人私信。sender_user_id 由 Hub 按发信人的**用户令牌**写入(不是客户端填的 from_session),
822
+ // 会话记录按它双向取;Agent / 节点令牌发来的桌面消息这一列为 NULL。
823
+ try { db.exec("ALTER TABLE user_inbox ADD COLUMN sender_user_id TEXT"); } catch {}
824
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_dm ON user_inbox(network_id, user_id, sender_user_id, created_at);`);
821
825
 
822
826
  db.exec(`
823
827
  CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
@@ -960,7 +964,7 @@ db.exec(`
960
964
  network_id TEXT NOT NULL,
961
965
  title TEXT NOT NULL,
962
966
  column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
963
- priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
967
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
964
968
  due_on TEXT,
965
969
  assignee TEXT,
966
970
  client_id TEXT,
@@ -974,6 +978,7 @@ db.exec(`
974
978
  `);
975
979
  try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
976
980
  try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
981
+ try { db.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'"); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
977
982
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
978
983
  for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT"]) {
979
984
  try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
@@ -1035,6 +1040,31 @@ db.exec(`
1035
1040
  );
1036
1041
  `);
1037
1042
 
1043
+ // ── 多用户账号与 Agent 权限:成员可见哪些 Agent ──
1044
+ // network_members.agent_access:
1045
+ // 'all' —— 旧语义:能看到并联系网络里所有 Agent。ALTER 的 DEFAULT 让**升级前已有的**成员行
1046
+ // 全部落在这里,生产上已有成员的可见范围不因升级而变。
1047
+ // 'granted' —— 只看到 network_member_agent_grants 里给了的 Agent。此后**新加入**的
1048
+ // member/viewer(管理员建号、POST members、邀请码)都以它入库,即默认零权限。
1049
+ // owner/admin 角色与 Hub 管理员不看这一列(永远全部可见),判定集中在 agent-access.ts。
1050
+ try { db.exec("ALTER TABLE network_members ADD COLUMN agent_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
1051
+ // node_id 优先(稳定);alias 只给没有 node_id 的旧会话用。两者恰有其一。
1052
+ db.exec(`
1053
+ CREATE TABLE IF NOT EXISTS network_member_agent_grants (
1054
+ network_id TEXT NOT NULL,
1055
+ user_id TEXT NOT NULL,
1056
+ node_id TEXT,
1057
+ alias TEXT,
1058
+ can_message INTEGER NOT NULL DEFAULT 1,
1059
+ created_by TEXT,
1060
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1061
+ CHECK ((node_id IS NULL) <> (alias IS NULL))
1062
+ );
1063
+
1064
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_grants_node ON network_member_agent_grants(network_id, user_id, node_id) WHERE node_id IS NOT NULL;
1065
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_grants_alias ON network_member_agent_grants(network_id, user_id, alias) WHERE alias IS NOT NULL;
1066
+ `);
1067
+
1038
1068
  // ── #84: node rename — rename_txn table (RFC-010 §4) ──
1039
1069
  // Single isolated table holding the rename 2PC transaction state. It doubles
1040
1070
  // as the alias_rename_log (RFC §4 risk #5): the audit log of completed renames
@@ -67,8 +67,9 @@ beforeAll(async () => {
67
67
  const adminId = admin.user!.user_id;
68
68
  ownerId = owner.user!.user_id;
69
69
  memberId = member.user!.user_id;
70
- expect(addNetworkMember(networkId, ownerId, "member", adminId).ok).toBe(true);
71
- expect(addNetworkMember(networkId, memberId, "member", adminId).ok).toBe(true);
70
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
71
+ expect(addNetworkMember(networkId, ownerId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
72
+ expect(addNetworkMember(networkId, memberId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
72
73
 
73
74
  const minted = createNetworkTokenForNode(ownerId, networkId, alias, nodeId);
74
75
  expect(minted.ok).toBe(true);
@@ -104,7 +104,8 @@ if (!userBNetworkId) throw new Error("userB default network not found");
104
104
  // Add userA as a member of userB's default network. This is what makes
105
105
  // the "same-network non-owner" test cell meaningful — without it, that
106
106
  // test would only prove cross-network denial, which is a weaker claim.
107
- const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId);
107
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
108
+ const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId, { agentAccess: "all" });
108
109
  if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`);
109
110
 
110
111
  const { bootServer } = await import("./server.js");