@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68
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/package.json +1 -1
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +30 -0
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +79 -1
- package/src/requirements-mcp.test.ts +3 -0
- package/src/requirements.ts +103 -33
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-tasks-http.test.ts +2 -1
- package/src/scheduled-tasks.ts +12 -0
- package/src/server.ts +345 -41
- package/src/tools.ts +98 -31
|
@@ -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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
71
|
-
expect(addNetworkMember(networkId,
|
|
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
|
-
|
|
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");
|
|
@@ -108,13 +108,14 @@ const N2 = nonAdminUserB.networkId;
|
|
|
108
108
|
const N3 = seedUser.networkId;
|
|
109
109
|
const N_FAKE = `net_503fake${STAMP}`;
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
112
|
+
if (!addNetworkMember(N1, nonAdminUserB.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
|
|
112
113
|
throw new Error("fixture: userB → N1 member failed");
|
|
113
114
|
}
|
|
114
115
|
// The admin needs write access to N1 to be issued a node ntok_ there
|
|
115
116
|
// (createNetworkTokenForNode refuses non-members and viewers). That
|
|
116
117
|
// token is the D15 probe: a ntok_ ISSUED BY an admin.
|
|
117
|
-
if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId).ok) {
|
|
118
|
+
if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
|
|
118
119
|
throw new Error("fixture: admin → N1 member failed");
|
|
119
120
|
}
|
|
120
121
|
// viewerOnlyUser must hold exactly one network, with role viewer, so the
|
|
@@ -122,15 +123,15 @@ if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId).ok)
|
|
|
122
123
|
// reachable. register() makes everyone owner of a default network, so
|
|
123
124
|
// that membership is dropped here.
|
|
124
125
|
db.run("DELETE FROM network_members WHERE user_id = ?1", [viewerOnlyUser.userId]);
|
|
125
|
-
if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId).ok) {
|
|
126
|
+
if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
|
|
126
127
|
throw new Error("fixture: viewerOnly → N1 viewer failed");
|
|
127
128
|
}
|
|
128
129
|
// mixedUser can write in N1 but only read in N2 — U14 asserts that an
|
|
129
130
|
// explicit param pointing at the viewer network is refused.
|
|
130
|
-
if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId).ok) {
|
|
131
|
+
if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
|
|
131
132
|
throw new Error("fixture: mixed → N1 member failed");
|
|
132
133
|
}
|
|
133
|
-
if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId).ok) {
|
|
134
|
+
if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId, { agentAccess: "all" }).ok) {
|
|
134
135
|
throw new Error("fixture: mixed → N2 viewer failed");
|
|
135
136
|
}
|
|
136
137
|
|