@sleep2agi/commhub-server 0.5.0-preview.13 → 0.5.0-preview.15

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +54 -19
  3. package/src/tools.ts +27 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.13",
3
+ "version": "0.5.0-preview.15",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -10,26 +10,45 @@ const PORT = Number(process.env.PORT) || 9200;
10
10
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
11
11
 
12
12
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
13
- function createServer(clientIP?: string): McpServer {
13
+ function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
14
14
  const server = new McpServer({
15
15
  name: "commhub",
16
- version: "0.4.1",
16
+ version: "0.5.0",
17
17
  });
18
- registerTools(server, clientIP);
18
+ registerTools(server, clientIP, enforceNetworkId);
19
19
  return server;
20
20
  }
21
21
 
22
22
  // ── Auth helper ─────────────────────────────────────
23
23
  function requireAuth(req: Request): Response | null {
24
- if (!AUTH_TOKEN) return null; // no token = open mode (dev)
25
- const header = req.headers.get("Authorization");
26
- if (header === `Bearer ${AUTH_TOKEN}`) return null;
27
- // Also check query param for MCP clients that can't set headers
24
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
28
25
  const url = new URL(req.url);
29
- if (url.searchParams.get("token") === AUTH_TOKEN) return null;
26
+ const token = header || url.searchParams.get("token") || "";
27
+
28
+ // V3: check api_tokens first
29
+ if (token) {
30
+ const resolved = resolveToken(token);
31
+ if (resolved) return null; // valid user token
32
+ }
33
+
34
+ // Legacy: check global COMMHUB_AUTH_TOKEN
35
+ if (!AUTH_TOKEN) return null; // no token = open mode (dev)
36
+ if (token === AUTH_TOKEN) return null;
37
+
30
38
  return Response.json({ error: "unauthorized" }, { status: 401 });
31
39
  }
32
40
 
41
+ // Extract user + network from request token (for authorization)
42
+ function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
43
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
44
+ const url = new URL(req.url);
45
+ const token = header || url.searchParams.get("token") || "";
46
+ if (!token) return null;
47
+ const resolved = resolveToken(token);
48
+ if (!resolved) return null;
49
+ return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
50
+ }
51
+
33
52
  // ── REST input schema ───────────────────────────────
34
53
  const TaskSchema = z.object({
35
54
  alias: z.string().min(1).max(200),
@@ -120,10 +139,13 @@ Bun.serve({
120
139
  if (authErr) return withCors(req, authErr);
121
140
  const fwd = req.headers.get("x-forwarded-for");
122
141
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
142
+ // V3: resolve token → enforce network_id in all MCP tools
143
+ const authCtx = resolveRequestAuth(req);
144
+ const enforceNetId = authCtx?.networkId || null;
123
145
  const transport = new WebStandardStreamableHTTPServerTransport({
124
146
  sessionIdGenerator: undefined,
125
147
  });
126
- const server = createServer(clientIP);
148
+ const server = createServer(clientIP, enforceNetId);
127
149
  await server.connect(transport);
128
150
  const response = await transport.handleRequest(req);
129
151
  // Disconnect after response to prevent McpServer leak
@@ -243,6 +265,10 @@ Bun.serve({
243
265
  const networkId = netDetailMatch[1];
244
266
  const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
245
267
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
268
+ // Ownership check: only owner or admin can view
269
+ if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
270
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
271
+ }
246
272
  // Get network stats
247
273
  const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
248
274
  const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
@@ -406,14 +432,22 @@ Bun.serve({
406
432
  // ── REST: stats summary ──
407
433
  if (url.pathname === "/api/stats") {
408
434
  const n = url.searchParams.get("network_id");
409
- const nw = n ? ` WHERE network_id = '${n}'` : "";
410
- const taskStats = db.query<any, []>(`SELECT status, COUNT(*) as count FROM tasks${nw} GROUP BY status`).all();
411
- const sessionStats = db.query<any, []>(`SELECT status, COUNT(*) as count FROM sessions${nw} GROUP BY status`).all();
412
- const totalTasks = db.query<{ cnt: number }, []>(`SELECT COUNT(*) as cnt FROM tasks${nw}`).get();
413
- const totalNodes = db.query<{ cnt: number }, []>(`SELECT COUNT(*) as cnt FROM nodes${nw}`).get();
414
- const recentTasks = db.query<any, []>(
415
- `SELECT task_id, from_name, to_name, status, created_at FROM tasks${nw} ORDER BY created_at DESC LIMIT 5`
416
- ).all();
435
+ // Parameterized queries to prevent SQL injection
436
+ const taskStats = n
437
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
438
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
439
+ const sessionStats = n
440
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
441
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
442
+ const totalTasks = n
443
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
444
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
445
+ const totalNodes = n
446
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
447
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
448
+ const recentTasks = n
449
+ ? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
450
+ : db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
417
451
  return withCors(req, Response.json({
418
452
  ok: true,
419
453
  network_id: n || null,
@@ -493,8 +527,9 @@ Bun.serve({
493
527
  params.push(limit);
494
528
 
495
529
  const rows = db.query(sql).all(...params);
496
- const statsFilter = netFilter ? ` WHERE network_id = '${netFilter}'` : "";
497
- const stats = db.query<any, []>(`SELECT status, COUNT(*) as count FROM tasks${statsFilter} GROUP BY status`).all();
530
+ const stats = netFilter
531
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
532
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
498
533
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
499
534
  }
500
535
 
package/src/tools.ts CHANGED
@@ -7,7 +7,9 @@ function ts(): string {
7
7
  return new Date().toTimeString().slice(0, 8);
8
8
  }
9
9
 
10
- export function registerTools(server: McpServer, clientIP?: string) {
10
+ export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
11
+ // If enforceNetworkId is set, override any client-supplied network_id
12
+ const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
11
13
  // ═══════════════════════════════════════════
12
14
  // Child Agent Tools (4)
13
15
  // ═══════════════════════════════════════════
@@ -39,12 +41,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
39
41
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
40
42
  },
41
43
  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 }) => {
42
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
44
+ const effectiveNetId = getNetworkId(netId);
45
+ console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
43
46
  const trimmedOutput = output?.slice(0, 4000);
44
47
 
45
48
  try {
46
49
  db.run("BEGIN IMMEDIATE");
47
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
50
+ // Only delete same-alias sessions within the same network (prevent cross-network alias conflict)
51
+ if (effectiveNetId) {
52
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
53
+ } else {
54
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
55
+ }
48
56
  db.run(
49
57
  `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, last_seen_at, updated_at)
50
58
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
@@ -270,9 +278,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
270
278
  {
271
279
  filter_status: z.string().max(50).optional(),
272
280
  filter_server: z.string().max(200).optional(),
281
+ network_id: z.string().max(200).optional().describe("Filter by network"),
273
282
  },
274
- async ({ filter_status, filter_server }) => {
275
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_server ? " server=" + filter_server : ""}`);
283
+ async ({ filter_status, filter_server, network_id: netId }) => {
284
+ const effectiveNetId = getNetworkId(netId);
285
+ console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
276
286
 
277
287
  const sessions = db.transaction(() => {
278
288
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
@@ -280,6 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
280
290
 
281
291
  let sql = "SELECT * FROM sessions WHERE 1=1";
282
292
  const params: any[] = [];
293
+ if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
283
294
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
284
295
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
285
296
  sql += " ORDER BY updated_at DESC";
@@ -339,6 +350,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
339
350
  network_id: z.string().max(200).optional().describe("Network scope"),
340
351
  },
341
352
  async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
353
+ const effectiveNetId = getNetworkId(netId);
342
354
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
343
355
  const id = uuidv4();
344
356
  // 事务:inbox + tasks 双写
@@ -347,12 +359,12 @@ export function registerTools(server: McpServer, clientIP?: string) {
347
359
  db.run(
348
360
  `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
349
361
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
350
- [id, alias, priority, task, context ?? null, from_session, netId ?? null]
362
+ [id, alias, priority, task, context ?? null, from_session, effectiveNetId]
351
363
  );
352
364
  db.run(
353
365
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
354
366
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
355
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, netId ?? null]
367
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
356
368
  );
357
369
  db.run("COMMIT");
358
370
  logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
@@ -576,11 +588,14 @@ export function registerTools(server: McpServer, clientIP?: string) {
576
588
  alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
577
589
  status: z.string().max(50).optional().describe("Filter by status"),
578
590
  from_name: z.string().max(200).optional().describe("Filter by sender"),
591
+ network_id: z.string().max(200).optional().describe("Filter by network"),
579
592
  limit: z.number().min(1).max(100).optional().default(20),
580
593
  },
581
- async ({ alias, status, from_name, limit }) => {
594
+ async ({ alias, status, from_name, network_id: netId, limit }) => {
595
+ const effectiveNetId = getNetworkId(netId);
582
596
  let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
583
597
  const params: any[] = [];
598
+ if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
584
599
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
585
600
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
586
601
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
@@ -672,11 +687,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
672
687
  message: z.string().min(1).max(10000),
673
688
  filter_server: z.string().max(200).optional(),
674
689
  filter_status: z.string().max(50).optional(),
690
+ network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
675
691
  },
676
- async ({ message, filter_server, filter_status }) => {
677
- console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${filter_server ? " [server=" + filter_server + "]" : ""}`);
692
+ async ({ message, filter_server, filter_status, network_id: netId }) => {
693
+ console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
678
694
  let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
679
695
  const params: any[] = [];
696
+ if (netId) { sql += " AND network_id = ?"; params.push(netId); }
680
697
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
681
698
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
682
699