@sleep2agi/commhub-server 0.5.0-preview.28 → 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
@@ -43,7 +43,7 @@ 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 (27 端点)
46
+ ## REST API (33 端点)
47
47
 
48
48
  | 端点 | 方法 | 说明 |
49
49
  |------|------|------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.28",
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
@@ -224,6 +224,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
224
224
  if (networkId) {
225
225
  const role = getUserNetworkRole(userId, networkId);
226
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" };
227
228
  }
228
229
  const token = generateToken();
229
230
  const tokenId = generateId("tok");
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) ──
@@ -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) {
@@ -553,19 +620,24 @@ Bun.serve({
553
620
  // Resolve network scope for REST queries — enforce isolation
554
621
  // Token-bound networkId takes precedence (ntok_ → forced), then query param
555
622
  const restAuth = resolveRequestAuth(req);
556
- const isAdmin = restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin";
557
- // ntok_ token has networkId forced; utok_ has null (uses query param or admin sees all)
558
- const restNetId = restAuth?.networkId || url.searchParams.get("network_id") || null;
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
+ }
559
628
 
560
629
  // ── REST: all sessions status ──
561
630
  if (url.pathname === "/api/status") {
562
631
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
563
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
564
- const netFilter = restNetId;
565
- const sql = netFilter
566
- ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
567
- : "SELECT * FROM sessions ORDER BY updated_at DESC";
568
- 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);
569
641
  return withCors(req, Response.json({ ok: true, sessions }));
570
642
  }
571
643
 
@@ -582,18 +654,40 @@ Bun.serve({
582
654
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
583
655
  }
584
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
+ }
585
674
  const id = crypto.randomUUID();
586
675
  const fromSession = body.from || "api";
587
676
  db.run(
588
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
589
- VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
590
- [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]
591
680
  );
592
681
  // SSE push: 秒达
593
- const pending = db.get<{ cnt: number }>(
594
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
595
- body.alias);
596
- 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 });
597
691
  return withCors(req, Response.json({ ok: true, message_id: id }));
598
692
  }
599
693
 
@@ -610,18 +704,25 @@ Bun.serve({
610
704
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
611
705
  }
612
706
  const body = parsed.data;
613
- 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";
614
714
  const params: any[] = [];
715
+ sql = addNetworkScope(sql, params, restScope);
615
716
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
616
717
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
617
- const targets = db.all<{ alias: string }>(sql, ...params);
718
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
618
719
  const ids: string[] = [];
619
720
  for (const t of targets) {
620
721
  const id = crypto.randomUUID();
621
722
  db.run(
622
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
623
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
624
- [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]
625
726
  );
626
727
  ids.push(id);
627
728
  }
@@ -679,36 +780,49 @@ Bun.serve({
679
780
 
680
781
  // ── REST: recent messages (for Dashboard communication graph) ──
681
782
  if (url.pathname === "/api/messages") {
682
- const limit = Number(url.searchParams.get("limit")) || 100;
783
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
683
784
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
684
- const rows = db.all(
685
- "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",
686
- 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);
687
791
  return withCors(req, Response.json({ ok: true, messages: rows }));
688
792
  }
689
793
 
690
794
  // ── REST: stats summary ──
691
795
  if (url.pathname === "/api/stats") {
692
- const n = url.searchParams.get("network_id");
693
- // Parameterized queries to prevent SQL injection
694
- const taskStats = n
695
- ? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", n)
696
- : db.all<any>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
697
- const sessionStats = n
698
- ? db.all<any>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status", n)
699
- : db.all<any>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status");
700
- const totalTasks = n
701
- ? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1", n)
702
- : db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks");
703
- const totalNodes = n
704
- ? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", n)
705
- : db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes");
706
- const recentTasks = n
707
- ? 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)
708
- : 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);
709
823
  return withCors(req, Response.json({
710
824
  ok: true,
711
- network_id: n || null,
825
+ network_id: restScope.networkId || null,
712
826
  tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
713
827
  sessions: { by_status: sessionStats },
714
828
  nodes: { total: totalNodes?.cnt || 0 },
@@ -741,10 +855,11 @@ Bun.serve({
741
855
  if (url.pathname === "/api/task_events") {
742
856
  const taskId = url.searchParams.get("task_id");
743
857
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
744
- let sql = "SELECT * FROM task_events";
858
+ let sql = "SELECT * FROM task_events WHERE 1=1";
745
859
  const params: any[] = [];
746
- if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
747
- 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}`;
748
863
  params.push(limit);
749
864
  const rows = db.all(sql, ...params);
750
865
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
@@ -754,10 +869,9 @@ Bun.serve({
754
869
  if (url.pathname === "/api/nodes") {
755
870
  const nodeId = url.searchParams.get("node_id");
756
871
  const alias = url.searchParams.get("alias");
757
- const netFilter = url.searchParams.get("network_id");
758
872
  let sql = "SELECT * FROM nodes WHERE 1=1";
759
873
  const params: any[] = [];
760
- if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
874
+ sql = addNetworkScope(sql, params, restScope);
761
875
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
762
876
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
763
877
  sql += " ORDER BY updated_at DESC";
@@ -771,12 +885,11 @@ Bun.serve({
771
885
  const status = url.searchParams.get("status");
772
886
  const toName = url.searchParams.get("to_name");
773
887
  const fromName = url.searchParams.get("from_name");
774
- const netFilter = restNetId || url.searchParams.get("network_id"); // token-enforced takes priority
775
888
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
776
889
 
777
890
  let sql = "SELECT * FROM tasks WHERE 1=1";
778
891
  const params: any[] = [];
779
- if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
892
+ sql = addNetworkScope(sql, params, restScope);
780
893
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
781
894
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
782
895
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -785,16 +898,22 @@ Bun.serve({
785
898
  params.push(limit);
786
899
 
787
900
  const rows = db.all(sql, ...params);
788
- const stats = netFilter
789
- ? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", netFilter)
790
- : 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);
791
906
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
792
907
  }
793
908
 
794
909
  // ── REST: recent completions ──
795
910
  if (url.pathname === "/api/completions") {
796
911
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
797
- 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);
798
917
  return withCors(req, Response.json({ ok: true, completions: rows }));
799
918
  }
800
919
 
@@ -915,8 +1034,8 @@ console.log(`
915
1034
  ║ Transport: Streamable HTTP (Bun native) ║
916
1035
  ║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
917
1036
  ║ ║
918
- ║ MCP: http://0.0.0.0:${PORT}/mcp ║
919
- ║ REST: http://0.0.0.0:${PORT}/api ║
920
- ║ 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 ║
921
1040
  ╚══════════════════════════════════════════════════╝
922
1041
  `);
package/src/tools.ts CHANGED
@@ -20,6 +20,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
20
20
  const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
21
21
  return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
22
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
+ };
23
37
  // ═══════════════════════════════════════════
24
38
  // Child Agent Tools (4)
25
39
  // ═══════════════════════════════════════════
@@ -52,6 +66,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
52
66
  },
53
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 }) => {
54
68
  const effectiveNetId = getNetworkId(netId);
69
+ if (!canWrite()) {
70
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
71
+ }
55
72
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
56
73
  const trimmedOutput = output?.slice(0, 4000);
57
74
 
@@ -83,16 +100,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
83
100
  // V2: sync tasks table — report_status(working) → tasks.running
84
101
  if (status === "working" && task) {
85
102
  try {
86
- const runResult = db.run(
87
- `UPDATE tasks SET status = 'running', started_at = datetime('now')
88
- WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
89
- [alias, task]
90
- );
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);
91
108
  if (runResult.changes > 0) {
92
109
  // Find task_id for logging
93
- const t = db.get<{ task_id: string }>(
94
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1",
95
- 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);
96
115
  if (t) logTaskEvent(t.task_id, null, "running", alias);
97
116
  }
98
117
  } catch {}
@@ -104,8 +123,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
104
123
  // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
105
124
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
106
125
  db.run(
107
- `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
108
- 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'))
109
128
  ON CONFLICT(node_id) DO UPDATE SET
110
129
  node_name = COALESCE(?2, nodes.node_name),
111
130
  alias = COALESCE(?3, nodes.alias),
@@ -115,16 +134,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
115
134
  channels = COALESCE(?7, nodes.channels),
116
135
  server = COALESCE(?8, nodes.server),
117
136
  hostname = COALESCE(?9, nodes.hostname),
137
+ network_id = COALESCE(?10, nodes.network_id),
118
138
  updated_at = datetime('now')`,
119
- [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]
120
140
  );
121
141
  } catch {}
122
142
  }
123
143
 
124
144
  // inbox uses alias for routing
125
- const row = db.get<{ cnt: number }>(
126
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
127
- 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);
128
149
 
129
150
  return {
130
151
  content: [
@@ -152,43 +173,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
152
173
  artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
153
174
  score: z.number().min(0).max(10).optional(),
154
175
  duration_minutes: z.number().min(0).optional(),
176
+ network_id: z.string().max(200).optional().describe("Network scope"),
155
177
  },
156
- async ({ alias, task, result, artifacts, score, duration_minutes }) => {
157
- 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]" : ""}`);
158
184
  const id = uuidv4();
159
- const taskUpdateChanges = db.transaction(() => {
160
- db.run(
161
- `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
162
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
163
- [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
164
- );
185
+ let updatedTaskId: string | null = null;
186
+ db.transaction(() => {
165
187
  db.run(
166
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
167
- WHERE alias = ?1`,
168
- [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]
169
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
+
170
198
  // V2: sync tasks table — try by task_id first, then by content
171
- const tu = db.run(
172
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
173
- WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
174
- [result.slice(0, 4000), task]
175
- );
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);
176
204
  if (tu.changes === 0) {
177
- const match = db.get<{ task_id: string }>(
178
- `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
179
- AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`,
180
- 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);
181
211
  if (match) {
182
- db.run(`UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2`,
183
- [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;
184
217
  }
218
+ } else {
219
+ updatedTaskId = task;
185
220
  }
186
- return tu.changes;
187
221
  });
188
222
  // Log event after transaction
189
- const updatedTaskId = taskUpdateChanges > 0 ? task : (db.get<{ task_id: string }>(
190
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1",
191
- alias)?.task_id);
192
223
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
193
224
 
194
225
  return {
@@ -205,16 +236,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
205
236
  limit: z.number().min(1).max(100).optional().default(10),
206
237
  },
207
238
  async ({ alias, limit }) => {
208
- const rows0 = db.get<{ cnt: number }>(
209
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
210
- 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);
211
244
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
212
- const rows = db.all(
213
- `SELECT id, type, priority, content, context, from_session, created_at
214
- FROM inbox WHERE session_name = ?1 AND acked = 0
215
- ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
216
- LIMIT ?2`,
217
- 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);
218
253
 
219
254
  return {
220
255
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
@@ -231,8 +266,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
231
266
  response: z.string().max(10000).optional(),
232
267
  },
233
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" }) }] };
234
271
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
235
- 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);
236
276
  if (result.changes === 0) {
237
277
  return {
238
278
  content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
@@ -240,10 +280,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
240
280
  }
241
281
  // V2: sync tasks table — ack_inbox means delivered→acked
242
282
  try {
243
- const ackResult = db.run(
244
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
245
- [message_id]
246
- );
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);
247
287
  if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
248
288
  } catch {}
249
289
  return {
@@ -270,7 +310,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
270
310
 
271
311
  const sessions = db.transaction(() => {
272
312
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
273
- 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);
274
317
 
275
318
  let sql = "SELECT * FROM sessions WHERE 1=1";
276
319
  const params: any[] = [];
@@ -281,8 +324,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
281
324
  return db.all(sql, ...params);
282
325
  });
283
326
 
284
- const summary = db.all(
285
- "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);
286
332
 
287
333
  return {
288
334
  content: [
@@ -300,14 +346,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
300
346
  "Get detailed status of a specific session by alias.",
301
347
  { alias: z.string().min(1).max(200).describe("Session alias") },
302
348
  async ({ alias }) => {
349
+ const effectiveNetId = getNetworkId(null);
303
350
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
304
- const session = db.get("SELECT * FROM sessions WHERE alias = ?1", alias);
305
- const pending = db.get<{ cnt: number }>(
306
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
307
- alias);
308
- const recent = db.all(
309
- "SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5",
310
- 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);
311
366
 
312
367
  return {
313
368
  content: [
@@ -359,23 +414,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
359
414
  db.run(
360
415
  `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
361
416
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
362
- [id, alias, priority, task, context ?? null, from_session, effectiveNetId]
417
+ [id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
363
418
  );
364
419
  db.run(
365
420
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
366
421
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
367
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
422
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
368
423
  );
369
424
  });
370
425
  logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
371
426
 
372
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
427
+ const session = scopedSessionStatus(alias, effectiveNetId);
373
428
 
374
429
  // SSE push by alias
375
- const pending = db.get<{ cnt: number }>(
376
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
377
- alias);
378
- 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 });
379
435
 
380
436
  return {
381
437
  content: [
@@ -401,18 +457,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
401
457
  from_session: z.string().max(200).optional().default("hub"),
402
458
  },
403
459
  async ({ alias, message, from_session }) => {
460
+ const effectiveNetId = getNetworkId(null);
404
461
  if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
405
462
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
406
463
  const id = uuidv4();
407
464
  db.run(
408
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
409
- VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
410
- [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]
411
468
  );
412
469
 
413
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
470
+ const session = scopedSessionStatus(alias, effectiveNetId);
414
471
 
415
- 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 });
416
473
 
417
474
  return {
418
475
  content: [
@@ -441,23 +498,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
441
498
  from_session: z.string().max(200).optional().default("hub"),
442
499
  },
443
500
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
501
+ const effectiveNetId = getNetworkId(null);
444
502
  if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
445
503
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
446
504
  const id = uuidv4();
447
505
  const replyLogged = db.transaction(() => {
448
506
  db.run(
449
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
450
- VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
451
- [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]
452
510
  );
453
511
 
454
512
  // 更新 tasks 表
455
513
  if (in_reply_to) {
456
- const result = db.run(
457
- `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
458
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
459
- [replyStatus, text, in_reply_to]
460
- );
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);
461
519
  if (result.changes === 0) {
462
520
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
463
521
  return false;
@@ -470,8 +528,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
470
528
  // Log event after commit (outside transaction)
471
529
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
472
530
 
473
- const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
474
- 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 });
475
533
 
476
534
  return {
477
535
  content: [{
@@ -491,11 +549,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
491
549
  from_session: z.string().max(200).optional().default("hub"),
492
550
  },
493
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" }) }] };
494
554
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
495
- const result = db.run(
496
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
497
- [task_id]
498
- );
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);
499
559
  if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
500
560
  return {
501
561
  content: [{
@@ -515,10 +575,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
515
575
  from_session: z.string().max(200).optional().default("hub"),
516
576
  },
517
577
  async ({ task_id, from_session }) => {
578
+ const effectiveNetId = getNetworkId(null);
518
579
  if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
519
580
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
520
581
  // Find the original task
521
- 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);
522
586
  if (!task) {
523
587
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
524
588
  }
@@ -527,22 +591,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
527
591
  }
528
592
  db.transaction(() => {
529
593
  // Reset task status
530
- db.run(
531
- `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
532
- WHERE task_id = ?1`,
533
- [task_id]
534
- );
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);
535
599
  // Re-queue in inbox with new ID (original ID may already exist)
536
600
  const retryInboxId = uuidv4();
537
601
  db.run(
538
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
539
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
540
- [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]
541
605
  );
542
606
  });
543
607
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
544
608
  // SSE push
545
- 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
+ }
546
612
  return {
547
613
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
548
614
  };
@@ -557,7 +623,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
557
623
  task_id: z.string().min(1).max(200).describe("Task ID to query"),
558
624
  },
559
625
  async ({ task_id }) => {
560
- 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);
561
631
  return {
562
632
  content: [{
563
633
  type: "text" as const,
@@ -591,8 +661,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
591
661
  const tasks = db.all(sql, ...params);
592
662
 
593
663
  // Stats
594
- const stats = db.all(
595
- "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);
596
669
 
597
670
  return {
598
671
  content: [{
@@ -613,16 +686,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
613
686
  from_session: z.string().max(200).optional().default("hub"),
614
687
  },
615
688
  async ({ task_id, reason, from_session }) => {
689
+ const effectiveNetId = getNetworkId(null);
616
690
  if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
617
691
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
618
- const result = db.run(
619
- `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
620
- WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
621
- [reason || "cancelled by " + from_session, task_id]
622
- );
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);
623
697
  // Also ack the inbox entry to prevent agent from picking it up
624
698
  if (result.changes > 0) {
625
- 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);
626
703
  logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
627
704
  }
628
705
  return {
@@ -641,9 +718,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
641
718
  from_session: z.string().max(200).optional().default("hub"),
642
719
  },
643
720
  async ({ task_id, new_alias, from_session }) => {
721
+ const effectiveNetId = getNetworkId(null);
644
722
  if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
645
723
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
646
- 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);
647
728
  if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
648
729
  if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
649
730
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
@@ -651,14 +732,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
651
732
  const oldAlias = task.to_name;
652
733
  db.transaction(() => {
653
734
  // Ack old inbox to prevent original agent from picking it up
654
- db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
655
- 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
+
656
745
  const newInboxId = uuidv4();
657
- db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
658
- [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]);
659
748
  });
660
749
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
661
- 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
+ }
662
753
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
663
754
  }
664
755
  );
@@ -673,22 +764,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
673
764
  network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
674
765
  },
675
766
  async ({ message, filter_server, filter_status, network_id: netId }) => {
676
- console.log(`[${ts()}] hub broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
677
- 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";
678
771
  const params: any[] = [];
679
- if (netId) { sql += " AND network_id = ?"; params.push(netId); }
772
+ sql = addScope(sql, params, effectiveNetId);
680
773
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
681
774
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
682
775
 
683
- const targets = db.all<{ alias: string }>(sql, ...params);
776
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
684
777
  const ids: string[] = [];
685
778
 
686
779
  for (const t of targets) {
687
780
  const id = uuidv4();
688
781
  db.run(
689
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
690
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
691
- [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]
692
785
  );
693
786
  ids.push(id);
694
787
  }
@@ -712,16 +805,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
712
805
  {
713
806
  since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
714
807
  alias: z.string().max(200).optional().describe("Filter by session alias"),
808
+ network_id: z.string().max(200).optional().describe("Filter by network"),
715
809
  limit: z.number().min(1).max(500).optional().default(50),
716
810
  },
717
- async ({ since, alias, limit }) => {
811
+ async ({ since, alias, network_id: netId, limit }) => {
812
+ const effectiveNetId = getNetworkId(netId);
718
813
  console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
719
814
  const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
720
815
  let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
721
816
  const params: any[] = [cutoff];
817
+ sql = addScope(sql, params, effectiveNetId);
722
818
 
723
819
  if (alias) {
724
- sql += " AND session_name = ?2";
820
+ sql += ` AND session_name = ?${params.length + 1}`;
725
821
  params.push(alias);
726
822
  }
727
823