@sleep2agi/commhub-server 0.5.0-preview.27 → 0.5.0-preview.29

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 CHANGED
@@ -18,7 +18,7 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
18
18
  - REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
19
19
  - Health: `http://0.0.0.0:9200/health`
20
20
 
21
- ## MCP 工具 (17 个)
21
+ ## MCP 工具 (18 个)
22
22
 
23
23
  ### Agent 端 (从 Agent 调用)
24
24
  | 工具 | 说明 |
@@ -43,22 +43,50 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
43
43
  | `get_session_status` | 单 session 详情 |
44
44
  | `broadcast` | 群发消息 |
45
45
 
46
- ## REST API
46
+ ## REST API (33 端点)
47
47
 
48
48
  | 端点 | 方法 | 说明 |
49
49
  |------|------|------|
50
50
  | `/health` | GET | 健康检查 (无需 auth) |
51
+ | `/mcp` | POST | MCP Streamable HTTP |
52
+ | **认证** | | |
53
+ | `/api/auth/register` | POST | 注册 → utok_ + ntok_ |
54
+ | `/api/auth/login` | POST | 登录 → utok_ |
55
+ | `/api/auth/me` | GET | 当前用户信息 |
56
+ | `/api/auth/me` | PUT | 修改资料 |
57
+ | `/api/auth/password` | POST | 修改密码 |
58
+ | `/api/auth/tokens` | GET | Token 列表 |
59
+ | `/api/auth/tokens` | POST | 创建 Token |
60
+ | `/api/auth/tokens/:id` | DELETE | 撤销 Token |
61
+ | `/api/auth/node-token` | POST | 创建节点网络 Token (ntok_) |
62
+ | **网络** | | |
63
+ | `/api/networks` | GET | 我的网络列表(成员网络) |
64
+ | `/api/networks` | POST | 创建网络 |
65
+ | `/api/networks/:id` | GET | 网络详情 + 统计 |
66
+ | `/api/networks/:id` | PUT | 重命名网络 |
67
+ | `/api/networks/:id` | DELETE | 删除网络 |
68
+ | `/api/networks/:id/members` | GET | 成员列表 |
69
+ | `/api/networks/:id/members` | POST | 添加成员 |
70
+ | `/api/networks/:id/members/:uid` | PUT | 修改成员角色 |
71
+ | `/api/networks/:id/members/:uid` | DELETE | 移除成员 |
72
+ | `/api/networks/:id/invite` | POST | 生成邀请码 |
73
+ | `/api/networks/join` | POST | 用邀请码加入 |
74
+ | **数据** | | |
51
75
  | `/api/status` | GET | 所有 session |
52
- | `/api/tasks` | GET | 任务列表 (支持 status/from_name/to_name/task_id/limit 过滤) |
53
- | `/api/nodes` | GET | 节点持久化信息 |
54
- | `/api/task_events` | GET | 任务审计日志 |
76
+ | `/api/tasks` | GET | 任务列表 |
77
+ | `/api/nodes` | GET | 节点信息 |
78
+ | `/api/stats` | GET | 统计汇总 |
55
79
  | `/api/messages` | GET | 消息列表 |
56
80
  | `/api/completions` | GET | 完成记录 |
57
- | `/mcp` | POST | MCP Streamable HTTP |
81
+ | `/api/task_events` | GET | 任务审计日志 |
82
+ | `/api/audit-log` | GET | 操作审计日志 |
83
+ | `/api/users` | GET | 用户列表 (admin) |
84
+ | `/api/license` | GET | License 状态 |
85
+ | `/api/license/activate` | POST | 激活授权码 |
58
86
 
59
- ## 数据表 (11 表)
87
+ ## 数据表 (13 表)
60
88
 
61
- 自动创建,支持 SQLite 和 PostgreSQL
89
+ 自动创建,SQLite
62
90
 
63
91
  | 表 | 说明 |
64
92
  |---|------|
@@ -68,6 +96,13 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
68
96
  | `nodes` | 持久化节点身份 (11 列, 独立于 session) |
69
97
  | `completions` | 完成记录 (7 列) |
70
98
  | `task_events` | 审计日志 (7 列, 每次状态变化记录) |
99
+ | `users` | 用户 (username/password_hash/role/plan) |
100
+ | `networks` | 网络 (name/owner/visibility/max_members) |
101
+ | `api_tokens` | API Token (utok_/ntok_/atok_ + scope + network) |
102
+ | `audit_log` | 操作审计 (user/action/target/ip) |
103
+ | `licenses` | License (type/expires/limits) |
104
+ | `network_members` | 网络成员 (user ↔ network + role) |
105
+ | `network_invites` | 邀请码 (code/role/max_uses/expires) |
71
106
 
72
107
  任务状态机:
73
108
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.27",
3
+ "version": "0.5.0-preview.29",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -22,6 +22,7 @@ export interface AuthResult {
22
22
 
23
23
  export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
24
24
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
25
+ if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
25
26
  if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
26
27
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
27
28
 
@@ -157,7 +158,23 @@ export function getUserNetworks(userId: string) {
157
158
  userId);
158
159
  }
159
160
 
161
+ // Quota limits by plan
162
+ const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
163
+ free: { max_networks_owned: 2, max_networks_joined: 3 },
164
+ pro: { max_networks_owned: 10, max_networks_joined: 20 },
165
+ admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },
166
+ };
167
+
160
168
  export function createNetwork(userId: string, name: string, description?: string) {
169
+ // Quota check
170
+ const user = db.get<any>("SELECT plan, role FROM users WHERE user_id = ?1", userId);
171
+ const plan = user?.role === "admin" ? "admin" : (user?.plan || "free");
172
+ const quota = QUOTAS[plan] || QUOTAS.free;
173
+ const ownedCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM networks WHERE owner_id = ?1", userId);
174
+ if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {
175
+ return { ok: false, error: `quota exceeded: max ${quota.max_networks_owned} networks for ${plan} plan` };
176
+ }
177
+
161
178
  const existing = db.get<any>(
162
179
  "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
163
180
  userId, name);
@@ -203,6 +220,12 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
203
220
  }
204
221
 
205
222
  export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
223
+ // Security: verify user is a member of the target network
224
+ if (networkId) {
225
+ const role = getUserNetworkRole(userId, networkId);
226
+ if (!role) return { ok: false, error: "not a member of this network" };
227
+ if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
228
+ }
206
229
  const token = generateToken();
207
230
  const tokenId = generateId("tok");
208
231
  db.run(
package/src/db-adapter.ts CHANGED
@@ -50,7 +50,7 @@ export class SQLiteAdapter implements DbAdapter {
50
50
  constructor(private readonly rawDb: Database) {}
51
51
 
52
52
  run(sql: string, params?: any[]): QueryResult {
53
- return this.rawDb.run(sql, params as any);
53
+ return params ? this.rawDb.run(sql, params as any) : this.rawDb.run(sql);
54
54
  }
55
55
 
56
56
  get<T = any>(sql: string, ...params: any[]): T | null {
package/src/db.ts CHANGED
@@ -46,6 +46,7 @@ db.exec(`
46
46
  artifacts TEXT,
47
47
  score REAL,
48
48
  duration_minutes REAL,
49
+ network_id TEXT,
49
50
  completed_at TEXT NOT NULL DEFAULT (datetime('now'))
50
51
  );
51
52
  `);
@@ -296,12 +297,15 @@ try {
296
297
  } catch {}
297
298
 
298
299
  // ── V3: add network_id to existing tables ──
299
- for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
300
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "completions"]) {
300
301
  try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
301
302
  }
302
303
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
303
304
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
304
305
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
306
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
307
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
308
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
305
309
 
306
310
  // Helpers
307
311
  export function uuidv4(): string {
@@ -344,7 +348,8 @@ export function logAudit(userId: string | null, username: string | null, action:
344
348
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
345
349
  try {
346
350
  db.run(
347
- "INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
351
+ `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
352
+ VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
348
353
  [taskId, fromStatus, toStatus, actor, detail ?? null]
349
354
  );
350
355
  } catch {}
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.j
7
7
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
 
9
9
  const PORT = Number(process.env.PORT) || 9200;
10
+ const HOST = process.env.HOST || "0.0.0.0";
10
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
11
12
 
12
13
  // ── Rate limiter (in-memory, per IP) ──
@@ -33,12 +34,12 @@ setInterval(() => {
33
34
  }, 300000);
34
35
 
35
36
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
36
- function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
37
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null): McpServer {
37
38
  const server = new McpServer({
38
39
  name: "commhub",
39
40
  version: "0.5.0",
40
41
  });
41
- registerTools(server, clientIP, enforceNetworkId);
42
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId);
42
43
  return server;
43
44
  }
44
45
 
@@ -72,12 +73,77 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
72
73
  return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
73
74
  }
74
75
 
76
+ type RestNetworkScope = {
77
+ networkId: string | null;
78
+ networkIds: string[] | null;
79
+ denied?: string;
80
+ };
81
+
82
+ function getUserNetworkIds(userId: string): string[] {
83
+ return db.all<{ network_id: string }>(
84
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
85
+ userId
86
+ ).map((row) => row.network_id);
87
+ }
88
+
89
+ function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
90
+ const requested = url.searchParams.get("network_id");
91
+
92
+ // Legacy global token or open dev mode keeps the old global behavior.
93
+ if (!authCtx) return { networkId: requested || null, networkIds: null };
94
+
95
+ // Network tokens are forcibly scoped to their bound network.
96
+ if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
97
+
98
+ // System admins may intentionally inspect all networks.
99
+ if (isAdmin) return { networkId: requested || null, networkIds: null };
100
+
101
+ if (requested) {
102
+ const role = getUserNetworkRole(authCtx.userId, requested);
103
+ if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
104
+ return { networkId: requested, networkIds: null };
105
+ }
106
+
107
+ return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
108
+ }
109
+
110
+ function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
111
+ if (scope.networkId) {
112
+ sql += ` AND ${column} = ?${params.length + 1}`;
113
+ params.push(scope.networkId);
114
+ } else if (scope.networkIds) {
115
+ if (scope.networkIds.length === 0) {
116
+ sql += " AND 1=0";
117
+ } else {
118
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
119
+ sql += ` AND ${column} IN (${placeholders})`;
120
+ params.push(...scope.networkIds);
121
+ }
122
+ }
123
+ return sql;
124
+ }
125
+
126
+ function singleNetworkId(scope: RestNetworkScope): string | null {
127
+ if (scope.networkId) return scope.networkId;
128
+ if (scope.networkIds?.length === 1) return scope.networkIds[0];
129
+ return null;
130
+ }
131
+
132
+ function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
133
+ if (!authCtx) return true; // legacy global token or open dev mode
134
+ if (isAdmin) return true;
135
+ if (!networkId) return false;
136
+ const role = getUserNetworkRole(authCtx.userId, networkId);
137
+ return !!role && role !== "viewer";
138
+ }
139
+
75
140
  // ── REST input schema ───────────────────────────────
76
141
  const TaskSchema = z.object({
77
142
  alias: z.string().min(1).max(200),
78
143
  task: z.string().min(1).max(10000),
79
144
  priority: z.enum(["high", "normal", "low"]).default("normal"),
80
145
  from: z.string().max(200).optional(),
146
+ network_id: z.string().max(200).optional(),
81
147
  });
82
148
 
83
149
  const BroadcastSchema = z.object({
@@ -137,6 +203,7 @@ setInterval(() => {
137
203
 
138
204
  Bun.serve({
139
205
  port: PORT,
206
+ hostname: HOST,
140
207
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
141
208
 
142
209
  async fetch(req, server) {
@@ -164,10 +231,18 @@ Bun.serve({
164
231
  // V3: resolve token → enforce network_id in all MCP tools
165
232
  const authCtx = resolveRequestAuth(req);
166
233
  const enforceNetId = authCtx?.networkId || null;
234
+ // utok_ (no network binding) cannot use MCP — only ntok_/atok_/global token
235
+ if (authCtx && !authCtx.networkId) {
236
+ return withCors(req, Response.json({
237
+ jsonrpc: "2.0",
238
+ error: { code: -32000, message: "User token (utok_) cannot access MCP. Use a network token (ntok_) instead." },
239
+ id: null,
240
+ }, { status: 403 }));
241
+ }
167
242
  const transport = new WebStandardStreamableHTTPServerTransport({
168
243
  sessionIdGenerator: undefined,
169
244
  });
170
- const server = createServer(clientIP, enforceNetId);
245
+ const server = createServer(clientIP, enforceNetId, authCtx?.userId || null);
171
246
  await server.connect(transport);
172
247
  const response = await transport.handleRequest(req);
173
248
  // Disconnect after response to prevent McpServer leak
@@ -261,7 +336,7 @@ Bun.serve({
261
336
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
262
337
  const resolved = resolveToken(token);
263
338
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
264
- const networks = getUserNetworks(resolved.user.user_id);
339
+ const networks = getUserAllNetworks(resolved.user.user_id);
265
340
  return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
266
341
  }
267
342
 
@@ -363,7 +438,12 @@ Bun.serve({
363
438
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
364
439
  const resolved = resolveToken(token);
365
440
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
366
- // V3.13: return all networks user is a member of (not just owner)
441
+ // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
442
+ if (resolved.networkId) {
443
+ // ntok_ — only return the bound network
444
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
445
+ return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
446
+ }
367
447
  const networks = getUserAllNetworks(resolved.user.user_id);
368
448
  return withCors(req, Response.json({ ok: true, networks }));
369
449
  }
@@ -469,8 +549,9 @@ Bun.serve({
469
549
  const networkId = netDetailMatch[1];
470
550
  const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
471
551
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
472
- // Ownership check: only owner or admin can view
473
- if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
552
+ // Membership check: must be a member or system admin
553
+ const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
554
+ if (!viewerRole && resolved.user.role !== "admin") {
474
555
  return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
475
556
  }
476
557
  // Get network stats
@@ -536,15 +617,27 @@ Bun.serve({
536
617
  const authErr = requireAuth(req);
537
618
  if (authErr) return withCors(req, authErr);
538
619
 
620
+ // Resolve network scope for REST queries — enforce isolation
621
+ // Token-bound networkId takes precedence (ntok_ → forced), then query param
622
+ const restAuth = resolveRequestAuth(req);
623
+ const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
624
+ const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
625
+ if (restScope.denied) {
626
+ return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
627
+ }
628
+
539
629
  // ── REST: all sessions status ──
540
630
  if (url.pathname === "/api/status") {
541
631
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
542
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
543
- const netFilter = url.searchParams.get("network_id");
544
- const sql = netFilter
545
- ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
546
- : "SELECT * FROM sessions ORDER BY updated_at DESC";
547
- const sessions = netFilter ? db.all(sql, netFilter) : db.all(sql);
632
+ const staleParams: any[] = [cutoff];
633
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
634
+ staleSql = addNetworkScope(staleSql, staleParams, restScope);
635
+ db.run(staleSql, staleParams);
636
+ const params: any[] = [];
637
+ let sql = "SELECT * FROM sessions WHERE 1=1";
638
+ sql = addNetworkScope(sql, params, restScope);
639
+ sql += " ORDER BY updated_at DESC";
640
+ const sessions = db.all(sql, ...params);
548
641
  return withCors(req, Response.json({ ok: true, sessions }));
549
642
  }
550
643
 
@@ -561,18 +654,40 @@ Bun.serve({
561
654
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
562
655
  }
563
656
  const body = parsed.data;
657
+ let taskNetId: string | null = null;
658
+ if (restAuth?.networkId) {
659
+ taskNetId = restAuth.networkId;
660
+ } else if (body.network_id) {
661
+ if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
662
+ return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
663
+ }
664
+ taskNetId = body.network_id;
665
+ } else {
666
+ taskNetId = restAuth ? singleNetworkId(restScope) : null;
667
+ }
668
+ if (restAuth && !taskNetId) {
669
+ return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
670
+ }
671
+ if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
672
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
673
+ }
564
674
  const id = crypto.randomUUID();
565
675
  const fromSession = body.from || "api";
566
676
  db.run(
567
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
568
- VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
569
- [id, body.alias, body.priority, body.task, fromSession]
677
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
678
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
679
+ [id, body.alias, body.priority, body.task, fromSession, taskNetId]
570
680
  );
571
681
  // SSE push: 秒达
572
- const pending = db.get<{ cnt: number }>(
573
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
574
- body.alias);
575
- pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
682
+ const pendingParams: any[] = [body.alias];
683
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
684
+ if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
685
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
686
+ const sessionParams: any[] = [body.alias];
687
+ let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
688
+ if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
689
+ const targetSession = db.get<any>(sessionSql, ...sessionParams);
690
+ if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
576
691
  return withCors(req, Response.json({ ok: true, message_id: id }));
577
692
  }
578
693
 
@@ -589,18 +704,25 @@ Bun.serve({
589
704
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
590
705
  }
591
706
  const body = parsed.data;
592
- let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
707
+ if (restAuth && !restScope.networkId && !isAdmin) {
708
+ return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
709
+ }
710
+ if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
711
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
712
+ }
713
+ let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
593
714
  const params: any[] = [];
715
+ sql = addNetworkScope(sql, params, restScope);
594
716
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
595
717
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
596
- const targets = db.all<{ alias: string }>(sql, ...params);
718
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
597
719
  const ids: string[] = [];
598
720
  for (const t of targets) {
599
721
  const id = crypto.randomUUID();
600
722
  db.run(
601
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
602
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
603
- [id, t.alias, body.message]
723
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
724
+ VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
725
+ [id, t.alias, body.message, t.network_id]
604
726
  );
605
727
  ids.push(id);
606
728
  }
@@ -658,36 +780,49 @@ Bun.serve({
658
780
 
659
781
  // ── REST: recent messages (for Dashboard communication graph) ──
660
782
  if (url.pathname === "/api/messages") {
661
- const limit = Number(url.searchParams.get("limit")) || 100;
783
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
662
784
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
663
- const rows = db.all(
664
- "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at FROM inbox WHERE created_at >= ?1 ORDER BY created_at DESC LIMIT ?2",
665
- since, limit);
785
+ const params: any[] = [since];
786
+ let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
787
+ sql = addNetworkScope(sql, params, restScope);
788
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
789
+ params.push(limit);
790
+ const rows = db.all(sql, ...params);
666
791
  return withCors(req, Response.json({ ok: true, messages: rows }));
667
792
  }
668
793
 
669
794
  // ── REST: stats summary ──
670
795
  if (url.pathname === "/api/stats") {
671
- const n = url.searchParams.get("network_id");
672
- // Parameterized queries to prevent SQL injection
673
- const taskStats = n
674
- ? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", n)
675
- : db.all<any>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
676
- const sessionStats = n
677
- ? db.all<any>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status", n)
678
- : db.all<any>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status");
679
- const totalTasks = n
680
- ? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1", n)
681
- : db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks");
682
- const totalNodes = n
683
- ? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", n)
684
- : db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes");
685
- const recentTasks = n
686
- ? db.all<any>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5", n)
687
- : db.all<any>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5");
796
+ const taskStatsParams: any[] = [];
797
+ let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
798
+ taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
799
+ taskStatsSql += " GROUP BY status";
800
+ const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
801
+
802
+ const sessionStatsParams: any[] = [];
803
+ let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
804
+ sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
805
+ sessionStatsSql += " GROUP BY status";
806
+ const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
807
+
808
+ const totalTasksParams: any[] = [];
809
+ let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
810
+ totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
811
+ const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
812
+
813
+ const totalNodesParams: any[] = [];
814
+ let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
815
+ totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
816
+ const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
817
+
818
+ const recentTasksParams: any[] = [];
819
+ let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
820
+ recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
821
+ recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
822
+ const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
688
823
  return withCors(req, Response.json({
689
824
  ok: true,
690
- network_id: n || null,
825
+ network_id: restScope.networkId || null,
691
826
  tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
692
827
  sessions: { by_status: sessionStats },
693
828
  nodes: { total: totalNodes?.cnt || 0 },
@@ -720,10 +855,11 @@ Bun.serve({
720
855
  if (url.pathname === "/api/task_events") {
721
856
  const taskId = url.searchParams.get("task_id");
722
857
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
723
- let sql = "SELECT * FROM task_events";
858
+ let sql = "SELECT * FROM task_events WHERE 1=1";
724
859
  const params: any[] = [];
725
- if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
726
- sql += " ORDER BY created_at DESC LIMIT ?";
860
+ sql = addNetworkScope(sql, params, restScope);
861
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
862
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
727
863
  params.push(limit);
728
864
  const rows = db.all(sql, ...params);
729
865
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
@@ -733,10 +869,9 @@ Bun.serve({
733
869
  if (url.pathname === "/api/nodes") {
734
870
  const nodeId = url.searchParams.get("node_id");
735
871
  const alias = url.searchParams.get("alias");
736
- const netFilter = url.searchParams.get("network_id");
737
872
  let sql = "SELECT * FROM nodes WHERE 1=1";
738
873
  const params: any[] = [];
739
- if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
874
+ sql = addNetworkScope(sql, params, restScope);
740
875
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
741
876
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
742
877
  sql += " ORDER BY updated_at DESC";
@@ -750,12 +885,11 @@ Bun.serve({
750
885
  const status = url.searchParams.get("status");
751
886
  const toName = url.searchParams.get("to_name");
752
887
  const fromName = url.searchParams.get("from_name");
753
- const netFilter = url.searchParams.get("network_id");
754
888
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
755
889
 
756
890
  let sql = "SELECT * FROM tasks WHERE 1=1";
757
891
  const params: any[] = [];
758
- if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
892
+ sql = addNetworkScope(sql, params, restScope);
759
893
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
760
894
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
761
895
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -764,16 +898,22 @@ Bun.serve({
764
898
  params.push(limit);
765
899
 
766
900
  const rows = db.all(sql, ...params);
767
- const stats = netFilter
768
- ? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", netFilter)
769
- : db.all<any>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
901
+ const statsParams: any[] = [];
902
+ let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
903
+ statsSql = addNetworkScope(statsSql, statsParams, restScope);
904
+ statsSql += " GROUP BY status";
905
+ const stats = db.all<any>(statsSql, ...statsParams);
770
906
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
771
907
  }
772
908
 
773
909
  // ── REST: recent completions ──
774
910
  if (url.pathname === "/api/completions") {
775
911
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
776
- const rows = db.all("SELECT * FROM completions WHERE completed_at >= ?1 ORDER BY completed_at DESC LIMIT 100", since);
912
+ const params: any[] = [since];
913
+ let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
914
+ sql = addNetworkScope(sql, params, restScope);
915
+ sql += " ORDER BY completed_at DESC LIMIT 100";
916
+ const rows = db.all(sql, ...params);
777
917
  return withCors(req, Response.json({ ok: true, completions: rows }));
778
918
  }
779
919
 
@@ -894,8 +1034,8 @@ console.log(`
894
1034
  ║ Transport: Streamable HTTP (Bun native) ║
895
1035
  ║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
896
1036
  ║ ║
897
- ║ MCP: http://0.0.0.0:${PORT}/mcp ║
898
- ║ REST: http://0.0.0.0:${PORT}/api ║
899
- ║ Health: http://0.0.0.0:${PORT}/health ║
1037
+ ║ MCP: http://${HOST}:${PORT}/mcp ║
1038
+ ║ REST: http://${HOST}:${PORT}/api ║
1039
+ ║ Health: http://${HOST}:${PORT}/health ║
900
1040
  ╚══════════════════════════════════════════════════╝
901
1041
  `);
package/src/tools.ts CHANGED
@@ -2,14 +2,38 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
3
  import { db, uuidv4, logTaskEvent } from "./db.js";
4
4
  import { pushEvent, pushBroadcast } from "./push.js";
5
+ import { getUserNetworkRole } from "./auth.js";
5
6
 
6
7
  function ts(): string {
7
8
  return new Date().toTimeString().slice(0, 8);
8
9
  }
9
10
 
10
- export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
11
+ export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null) {
11
12
  // If enforceNetworkId is set, override any client-supplied network_id
12
13
  const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
14
+
15
+ // Check if the user has write access to the enforced network
16
+ // utok_ (no networkId) cannot do MCP writes — only ntok_/atok_ with network binding can
17
+ const canWrite = (): boolean => {
18
+ if (!enforceUserId) return true; // legacy global token mode, allow
19
+ if (!enforceNetworkId) return false; // utok_ has no network → cannot write MCP
20
+ const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
21
+ return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
22
+ };
23
+
24
+ const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
25
+ if (!networkId) return sql;
26
+ sql += ` AND ${column} = ?${params.length + 1}`;
27
+ params.push(networkId);
28
+ return sql;
29
+ };
30
+
31
+ const scopedSessionStatus = (alias: string, networkId?: string | null) => {
32
+ const params: any[] = [alias];
33
+ let sql = "SELECT status FROM sessions WHERE alias = ?1";
34
+ sql = addScope(sql, params, networkId);
35
+ return db.get<any>(sql, ...params);
36
+ };
13
37
  // ═══════════════════════════════════════════
14
38
  // Child Agent Tools (4)
15
39
  // ═══════════════════════════════════════════
@@ -42,6 +66,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
42
66
  },
43
67
  async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId }) => {
44
68
  const effectiveNetId = getNetworkId(netId);
69
+ if (!canWrite()) {
70
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
71
+ }
45
72
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
46
73
  const trimmedOutput = output?.slice(0, 4000);
47
74
 
@@ -66,23 +93,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
66
93
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
67
94
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
68
95
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
69
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, netId ?? null]
96
+ [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, effectiveNetId ?? null]
70
97
  );
71
98
  });
72
99
 
73
100
  // V2: sync tasks table — report_status(working) → tasks.running
74
101
  if (status === "working" && task) {
75
102
  try {
76
- const runResult = db.run(
77
- `UPDATE tasks SET status = 'running', started_at = datetime('now')
78
- WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
79
- [alias, task]
80
- );
103
+ const runParams: any[] = [alias, task];
104
+ let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
105
+ WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
106
+ runSql = addScope(runSql, runParams, effectiveNetId);
107
+ const runResult = db.run(runSql, runParams);
81
108
  if (runResult.changes > 0) {
82
109
  // Find task_id for logging
83
- const t = db.get<{ task_id: string }>(
84
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1",
85
- alias, task);
110
+ const findParams: any[] = [alias, task];
111
+ let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
112
+ findSql = addScope(findSql, findParams, effectiveNetId);
113
+ findSql += " ORDER BY started_at DESC LIMIT 1";
114
+ const t = db.get<{ task_id: string }>(findSql, ...findParams);
86
115
  if (t) logTaskEvent(t.task_id, null, "running", alias);
87
116
  }
88
117
  } catch {}
@@ -94,8 +123,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
94
123
  // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
95
124
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
96
125
  db.run(
97
- `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
98
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
126
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
127
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
99
128
  ON CONFLICT(node_id) DO UPDATE SET
100
129
  node_name = COALESCE(?2, nodes.node_name),
101
130
  alias = COALESCE(?3, nodes.alias),
@@ -105,16 +134,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
105
134
  channels = COALESCE(?7, nodes.channels),
106
135
  server = COALESCE(?8, nodes.server),
107
136
  hostname = COALESCE(?9, nodes.hostname),
137
+ network_id = COALESCE(?10, nodes.network_id),
108
138
  updated_at = datetime('now')`,
109
- [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
139
+ [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
110
140
  );
111
141
  } catch {}
112
142
  }
113
143
 
114
144
  // inbox uses alias for routing
115
- const row = db.get<{ cnt: number }>(
116
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
117
- alias);
145
+ const inboxParams: any[] = [alias];
146
+ let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
147
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
148
+ const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
118
149
 
119
150
  return {
120
151
  content: [
@@ -142,43 +173,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
142
173
  artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
143
174
  score: z.number().min(0).max(10).optional(),
144
175
  duration_minutes: z.number().min(0).optional(),
176
+ network_id: z.string().max(200).optional().describe("Network scope"),
145
177
  },
146
- async ({ alias, task, result, artifacts, score, duration_minutes }) => {
147
- console.log(`[${ts()}] ${alias} report_completion: ${task.slice(0, 60)}`);
178
+ async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
179
+ const effectiveNetId = getNetworkId(netId);
180
+ if (!canWrite()) {
181
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
182
+ }
183
+ console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
148
184
  const id = uuidv4();
149
- const taskUpdateChanges = db.transaction(() => {
150
- db.run(
151
- `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
152
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
153
- [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
154
- );
185
+ let updatedTaskId: string | null = null;
186
+ db.transaction(() => {
155
187
  db.run(
156
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
157
- WHERE alias = ?1`,
158
- [alias]
188
+ `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes, network_id)
189
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
190
+ [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null, effectiveNetId ?? null]
159
191
  );
192
+ const sessionParams: any[] = [alias];
193
+ let sessionSql = `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
194
+ WHERE alias = ?1`;
195
+ sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
196
+ db.run(sessionSql, sessionParams);
197
+
160
198
  // V2: sync tasks table — try by task_id first, then by content
161
- const tu = db.run(
162
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
163
- WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
164
- [result.slice(0, 4000), task]
165
- );
199
+ const taskParams: any[] = [result.slice(0, 4000), task];
200
+ let taskSql = `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
201
+ WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`;
202
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
203
+ const tu = db.run(taskSql, taskParams);
166
204
  if (tu.changes === 0) {
167
- const match = db.get<{ task_id: string }>(
168
- `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
169
- AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`,
170
- alias, task);
205
+ const matchParams: any[] = [alias, task];
206
+ let matchSql = `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
207
+ AND status IN ('delivered', 'acked', 'running')`;
208
+ matchSql = addScope(matchSql, matchParams, effectiveNetId);
209
+ matchSql += " ORDER BY created_at DESC LIMIT 1";
210
+ const match = db.get<{ task_id: string }>(matchSql, ...matchParams);
171
211
  if (match) {
172
- db.run(`UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2`,
173
- [result.slice(0, 4000), match.task_id]);
212
+ const matchUpdateParams: any[] = [result.slice(0, 4000), match.task_id];
213
+ let matchUpdateSql = "UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2";
214
+ matchUpdateSql = addScope(matchUpdateSql, matchUpdateParams, effectiveNetId);
215
+ db.run(matchUpdateSql, matchUpdateParams);
216
+ updatedTaskId = match.task_id;
174
217
  }
218
+ } else {
219
+ updatedTaskId = task;
175
220
  }
176
- return tu.changes;
177
221
  });
178
222
  // Log event after transaction
179
- const updatedTaskId = taskUpdateChanges > 0 ? task : (db.get<{ task_id: string }>(
180
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1",
181
- alias)?.task_id);
182
223
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
183
224
 
184
225
  return {
@@ -195,16 +236,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
195
236
  limit: z.number().min(1).max(100).optional().default(10),
196
237
  },
197
238
  async ({ alias, limit }) => {
198
- const rows0 = db.get<{ cnt: number }>(
199
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
200
- alias);
239
+ const effectiveNetId = getNetworkId(null);
240
+ const countParams: any[] = [alias];
241
+ let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
242
+ countSql = addScope(countSql, countParams, effectiveNetId);
243
+ const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
201
244
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
202
- const rows = db.all(
203
- `SELECT id, type, priority, content, context, from_session, created_at
204
- FROM inbox WHERE session_name = ?1 AND acked = 0
205
- ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
206
- LIMIT ?2`,
207
- alias, limit);
245
+ const rowsParams: any[] = [alias];
246
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
247
+ FROM inbox WHERE session_name = ?1 AND acked = 0`;
248
+ rowsSql = addScope(rowsSql, rowsParams, effectiveNetId);
249
+ rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
250
+ LIMIT ?${rowsParams.length + 1}`;
251
+ rowsParams.push(limit);
252
+ const rows = db.all(rowsSql, ...rowsParams);
208
253
 
209
254
  return {
210
255
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
@@ -221,8 +266,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
221
266
  response: z.string().max(10000).optional(),
222
267
  },
223
268
  async ({ alias, message_id, response }) => {
269
+ const effectiveNetId = getNetworkId(null);
270
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
224
271
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
225
- const result = db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2", [message_id, alias]);
272
+ const ackParams: any[] = [message_id, alias];
273
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
274
+ ackSql = addScope(ackSql, ackParams, effectiveNetId);
275
+ const result = db.run(ackSql, ackParams);
226
276
  if (result.changes === 0) {
227
277
  return {
228
278
  content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
@@ -230,10 +280,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
230
280
  }
231
281
  // V2: sync tasks table — ack_inbox means delivered→acked
232
282
  try {
233
- const ackResult = db.run(
234
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
235
- [message_id]
236
- );
283
+ const taskParams: any[] = [message_id];
284
+ let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
285
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
286
+ const ackResult = db.run(taskSql, taskParams);
237
287
  if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
238
288
  } catch {}
239
289
  return {
@@ -260,7 +310,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
260
310
 
261
311
  const sessions = db.transaction(() => {
262
312
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
263
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
313
+ const staleParams: any[] = [cutoff];
314
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
315
+ staleSql = addScope(staleSql, staleParams, effectiveNetId);
316
+ db.run(staleSql, staleParams);
264
317
 
265
318
  let sql = "SELECT * FROM sessions WHERE 1=1";
266
319
  const params: any[] = [];
@@ -271,8 +324,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
271
324
  return db.all(sql, ...params);
272
325
  });
273
326
 
274
- const summary = db.all(
275
- "SELECT status, COUNT(*) as count FROM sessions GROUP BY status");
327
+ const summaryParams: any[] = [];
328
+ let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
329
+ summarySql = addScope(summarySql, summaryParams, effectiveNetId);
330
+ summarySql += " GROUP BY status";
331
+ const summary = db.all(summarySql, ...summaryParams);
276
332
 
277
333
  return {
278
334
  content: [
@@ -290,14 +346,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
290
346
  "Get detailed status of a specific session by alias.",
291
347
  { alias: z.string().min(1).max(200).describe("Session alias") },
292
348
  async ({ alias }) => {
349
+ const effectiveNetId = getNetworkId(null);
293
350
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
294
- const session = db.get("SELECT * FROM sessions WHERE alias = ?1", alias);
295
- const pending = db.get<{ cnt: number }>(
296
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
297
- alias);
298
- const recent = db.all(
299
- "SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5",
300
- alias);
351
+ const sessionParams: any[] = [alias];
352
+ let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
353
+ sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
354
+ const session = db.get(sessionSql, ...sessionParams);
355
+
356
+ const pendingParams: any[] = [alias];
357
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
358
+ pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
359
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
360
+
361
+ const recentParams: any[] = [alias];
362
+ let recentSql = "SELECT * FROM completions WHERE session_name = ?1";
363
+ recentSql = addScope(recentSql, recentParams, effectiveNetId);
364
+ recentSql += " ORDER BY completed_at DESC LIMIT 5";
365
+ const recent = db.all(recentSql, ...recentParams);
301
366
 
302
367
  return {
303
368
  content: [
@@ -325,6 +390,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
325
390
  async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
326
391
  const effectiveNetId = getNetworkId(netId);
327
392
 
393
+ // Role check: viewer cannot send tasks
394
+ if (!canWrite()) {
395
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
396
+ }
397
+
328
398
  // License check
329
399
  const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
330
400
  if (license?.expires_at) {
@@ -344,23 +414,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
344
414
  db.run(
345
415
  `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
346
416
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
347
- [id, alias, priority, task, context ?? null, from_session, effectiveNetId]
417
+ [id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
348
418
  );
349
419
  db.run(
350
420
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
351
421
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
352
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
422
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
353
423
  );
354
424
  });
355
425
  logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
356
426
 
357
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
427
+ const session = scopedSessionStatus(alias, effectiveNetId);
358
428
 
359
429
  // SSE push by alias
360
- const pending = db.get<{ cnt: number }>(
361
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
362
- alias);
363
- pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
430
+ const pendingParams: any[] = [alias];
431
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
432
+ pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
433
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
434
+ if (session) pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
364
435
 
365
436
  return {
366
437
  content: [
@@ -386,17 +457,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
386
457
  from_session: z.string().max(200).optional().default("hub"),
387
458
  },
388
459
  async ({ alias, message, from_session }) => {
460
+ const effectiveNetId = getNetworkId(null);
461
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
389
462
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
390
463
  const id = uuidv4();
391
464
  db.run(
392
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
393
- VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
394
- [id, alias, message, from_session]
465
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
466
+ VALUES (?1, ?2, 'message', 'normal', ?3, ?4, ?5)`,
467
+ [id, alias, message, from_session, effectiveNetId ?? null]
395
468
  );
396
469
 
397
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
470
+ const session = scopedSessionStatus(alias, effectiveNetId);
398
471
 
399
- pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
472
+ if (session) pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
400
473
 
401
474
  return {
402
475
  content: [
@@ -425,22 +498,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
425
498
  from_session: z.string().max(200).optional().default("hub"),
426
499
  },
427
500
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
501
+ const effectiveNetId = getNetworkId(null);
502
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
428
503
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
429
504
  const id = uuidv4();
430
505
  const replyLogged = db.transaction(() => {
431
506
  db.run(
432
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
433
- VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
434
- [id, alias, text, from_session, in_reply_to ?? null]
507
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
508
+ VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
509
+ [id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
435
510
  );
436
511
 
437
512
  // 更新 tasks 表
438
513
  if (in_reply_to) {
439
- const result = db.run(
440
- `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
441
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
442
- [replyStatus, text, in_reply_to]
443
- );
514
+ const updateParams: any[] = [replyStatus, text, in_reply_to];
515
+ let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
516
+ WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
517
+ updateSql = addScope(updateSql, updateParams, effectiveNetId);
518
+ const result = db.run(updateSql, updateParams);
444
519
  if (result.changes === 0) {
445
520
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
446
521
  return false;
@@ -453,8 +528,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
453
528
  // Log event after commit (outside transaction)
454
529
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
455
530
 
456
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
457
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
531
+ const session = scopedSessionStatus(alias, effectiveNetId);
532
+ if (session) pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
458
533
 
459
534
  return {
460
535
  content: [{
@@ -474,11 +549,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
474
549
  from_session: z.string().max(200).optional().default("hub"),
475
550
  },
476
551
  async ({ task_id, from_session }) => {
552
+ const effectiveNetId = getNetworkId(null);
553
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
477
554
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
478
- const result = db.run(
479
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
480
- [task_id]
481
- );
555
+ const updateParams: any[] = [task_id];
556
+ let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
557
+ updateSql = addScope(updateSql, updateParams, effectiveNetId);
558
+ const result = db.run(updateSql, updateParams);
482
559
  if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
483
560
  return {
484
561
  content: [{
@@ -498,9 +575,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
498
575
  from_session: z.string().max(200).optional().default("hub"),
499
576
  },
500
577
  async ({ task_id, from_session }) => {
578
+ const effectiveNetId = getNetworkId(null);
579
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
501
580
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
502
581
  // Find the original task
503
- const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
582
+ const taskParams: any[] = [task_id];
583
+ let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
584
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
585
+ const task = db.get<any>(taskSql, ...taskParams);
504
586
  if (!task) {
505
587
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
506
588
  }
@@ -509,22 +591,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
509
591
  }
510
592
  db.transaction(() => {
511
593
  // Reset task status
512
- db.run(
513
- `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
514
- WHERE task_id = ?1`,
515
- [task_id]
516
- );
594
+ const updateParams: any[] = [task_id];
595
+ let updateSql = `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
596
+ WHERE task_id = ?1`;
597
+ updateSql = addScope(updateSql, updateParams, effectiveNetId);
598
+ db.run(updateSql, updateParams);
517
599
  // Re-queue in inbox with new ID (original ID may already exist)
518
600
  const retryInboxId = uuidv4();
519
601
  db.run(
520
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
521
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
522
- [retryInboxId, task.to_name, task.priority, task.content, from_session]
602
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
603
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
604
+ [retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
523
605
  );
524
606
  });
525
607
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
526
608
  // SSE push
527
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
609
+ if (scopedSessionStatus(task.to_name, effectiveNetId ?? task.network_id)) {
610
+ pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
611
+ }
528
612
  return {
529
613
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
530
614
  };
@@ -539,7 +623,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
539
623
  task_id: z.string().min(1).max(200).describe("Task ID to query"),
540
624
  },
541
625
  async ({ task_id }) => {
542
- const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
626
+ const effectiveNetId = getNetworkId(null);
627
+ const params: any[] = [task_id];
628
+ let sql = "SELECT * FROM tasks WHERE task_id = ?1";
629
+ sql = addScope(sql, params, effectiveNetId);
630
+ const task = db.get<any>(sql, ...params);
543
631
  return {
544
632
  content: [{
545
633
  type: "text" as const,
@@ -573,8 +661,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
573
661
  const tasks = db.all(sql, ...params);
574
662
 
575
663
  // Stats
576
- const stats = db.all(
577
- "SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
664
+ const statsParams: any[] = [];
665
+ let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
666
+ statsSql = addScope(statsSql, statsParams, effectiveNetId);
667
+ statsSql += " GROUP BY status";
668
+ const stats = db.all(statsSql, ...statsParams);
578
669
 
579
670
  return {
580
671
  content: [{
@@ -595,15 +686,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
595
686
  from_session: z.string().max(200).optional().default("hub"),
596
687
  },
597
688
  async ({ task_id, reason, from_session }) => {
689
+ const effectiveNetId = getNetworkId(null);
690
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
598
691
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
599
- const result = db.run(
600
- `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
601
- WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
602
- [reason || "cancelled by " + from_session, task_id]
603
- );
692
+ const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
693
+ let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
694
+ WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
695
+ updateSql = addScope(updateSql, updateParams, effectiveNetId);
696
+ const result = db.run(updateSql, updateParams);
604
697
  // Also ack the inbox entry to prevent agent from picking it up
605
698
  if (result.changes > 0) {
606
- db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
699
+ const inboxParams: any[] = [task_id];
700
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
701
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
702
+ db.run(inboxSql, inboxParams);
607
703
  logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
608
704
  }
609
705
  return {
@@ -622,8 +718,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
622
718
  from_session: z.string().max(200).optional().default("hub"),
623
719
  },
624
720
  async ({ task_id, new_alias, from_session }) => {
721
+ const effectiveNetId = getNetworkId(null);
722
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
625
723
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
626
- const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
724
+ const taskParams: any[] = [task_id];
725
+ let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
726
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
727
+ const task = db.get<any>(taskSql, ...taskParams);
627
728
  if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
628
729
  if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
629
730
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
@@ -631,14 +732,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
631
732
  const oldAlias = task.to_name;
632
733
  db.transaction(() => {
633
734
  // Ack old inbox to prevent original agent from picking it up
634
- db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
635
- db.run("UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2", [new_alias, task_id]);
735
+ const inboxParams: any[] = [task_id];
736
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
737
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
738
+ db.run(inboxSql, inboxParams);
739
+
740
+ const updateParams: any[] = [new_alias, task_id];
741
+ let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2";
742
+ updateSql = addScope(updateSql, updateParams, effectiveNetId);
743
+ db.run(updateSql, updateParams);
744
+
636
745
  const newInboxId = uuidv4();
637
- db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
638
- [newInboxId, new_alias, task.priority, task.content, from_session]);
746
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)",
747
+ [newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
639
748
  });
640
749
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
641
- pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
750
+ if (scopedSessionStatus(new_alias, effectiveNetId ?? task.network_id)) {
751
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
752
+ }
642
753
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
643
754
  }
644
755
  );
@@ -653,22 +764,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
653
764
  network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
654
765
  },
655
766
  async ({ message, filter_server, filter_status, network_id: netId }) => {
656
- console.log(`[${ts()}] hub broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
657
- let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
767
+ const effectiveNetId = getNetworkId(netId);
768
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
769
+ console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
770
+ let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
658
771
  const params: any[] = [];
659
- if (netId) { sql += " AND network_id = ?"; params.push(netId); }
772
+ sql = addScope(sql, params, effectiveNetId);
660
773
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
661
774
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
662
775
 
663
- const targets = db.all<{ alias: string }>(sql, ...params);
776
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
664
777
  const ids: string[] = [];
665
778
 
666
779
  for (const t of targets) {
667
780
  const id = uuidv4();
668
781
  db.run(
669
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
670
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
671
- [id, t.alias, message]
782
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
783
+ VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub', ?4)`,
784
+ [id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
672
785
  );
673
786
  ids.push(id);
674
787
  }
@@ -692,16 +805,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
692
805
  {
693
806
  since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
694
807
  alias: z.string().max(200).optional().describe("Filter by session alias"),
808
+ network_id: z.string().max(200).optional().describe("Filter by network"),
695
809
  limit: z.number().min(1).max(500).optional().default(50),
696
810
  },
697
- async ({ since, alias, limit }) => {
811
+ async ({ since, alias, network_id: netId, limit }) => {
812
+ const effectiveNetId = getNetworkId(netId);
698
813
  console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
699
814
  const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
700
815
  let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
701
816
  const params: any[] = [cutoff];
817
+ sql = addScope(sql, params, effectiveNetId);
702
818
 
703
819
  if (alias) {
704
- sql += " AND session_name = ?2";
820
+ sql += ` AND session_name = ?${params.length + 1}`;
705
821
  params.push(alias);
706
822
  }
707
823