@sleep2agi/commhub-server 0.5.0-preview.14 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.14",
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'))
@@ -273,7 +281,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
273
281
  network_id: z.string().max(200).optional().describe("Filter by network"),
274
282
  },
275
283
  async ({ filter_status, filter_server, network_id: netId }) => {
276
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${netId ? " net=" + netId.slice(0, 12) : ""}`);
284
+ const effectiveNetId = getNetworkId(netId);
285
+ console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
277
286
 
278
287
  const sessions = db.transaction(() => {
279
288
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
@@ -281,7 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
281
290
 
282
291
  let sql = "SELECT * FROM sessions WHERE 1=1";
283
292
  const params: any[] = [];
284
- if (netId) { sql += " AND network_id = ?"; params.push(netId); }
293
+ if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
285
294
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
286
295
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
287
296
  sql += " ORDER BY updated_at DESC";
@@ -341,6 +350,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
341
350
  network_id: z.string().max(200).optional().describe("Network scope"),
342
351
  },
343
352
  async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
353
+ const effectiveNetId = getNetworkId(netId);
344
354
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
345
355
  const id = uuidv4();
346
356
  // 事务:inbox + tasks 双写
@@ -349,12 +359,12 @@ export function registerTools(server: McpServer, clientIP?: string) {
349
359
  db.run(
350
360
  `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
351
361
  VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
352
- [id, alias, priority, task, context ?? null, from_session, netId ?? null]
362
+ [id, alias, priority, task, context ?? null, from_session, effectiveNetId]
353
363
  );
354
364
  db.run(
355
365
  `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
356
366
  VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
357
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, netId ?? null]
367
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
358
368
  );
359
369
  db.run("COMMIT");
360
370
  logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
@@ -582,9 +592,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
582
592
  limit: z.number().min(1).max(100).optional().default(20),
583
593
  },
584
594
  async ({ alias, status, from_name, network_id: netId, limit }) => {
595
+ const effectiveNetId = getNetworkId(netId);
585
596
  let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
586
597
  const params: any[] = [];
587
- if (netId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netId); }
598
+ if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
588
599
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
589
600
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
590
601
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }