@sleep2agi/commhub-server 0.6.0 → 0.8.0-preview.0
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/LICENSE +202 -0
- package/README.md +5 -5
- package/bin/commhub.ts +11 -2
- package/package.json +9 -3
- package/src/auth.ts +49 -6
- package/src/db-adapter.ts +7 -9
- package/src/db.ts +79 -2
- package/src/index.ts +137 -39
- package/src/password-dict.ts +100 -0
- package/src/push.ts +17 -19
- package/src/tools.ts +77 -34
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
|
|
4
|
-
import { pushEvent
|
|
4
|
+
import { pushEvent } from "./push.js";
|
|
5
5
|
import { getUserNetworkRole } from "./auth.js";
|
|
6
6
|
|
|
7
7
|
function ts(): string {
|
|
@@ -36,6 +36,44 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
36
36
|
return sql;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
|
|
40
|
+
|
|
41
|
+
const getReadableNetworkIds = (): string[] => {
|
|
42
|
+
if (!enforceUserId) return [];
|
|
43
|
+
return db.all<{ network_id: string }>(
|
|
44
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
45
|
+
enforceUserId
|
|
46
|
+
).map((row) => row.network_id);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
50
|
+
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
51
|
+
if (enforceNetworkId) {
|
|
52
|
+
const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
|
|
53
|
+
return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
|
|
54
|
+
}
|
|
55
|
+
if (clientNetId) {
|
|
56
|
+
const role = getUserNetworkRole(enforceUserId, clientNetId);
|
|
57
|
+
return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
|
|
58
|
+
}
|
|
59
|
+
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
|
|
63
|
+
if (scope.networkId) {
|
|
64
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
65
|
+
params.push(scope.networkId);
|
|
66
|
+
return sql;
|
|
67
|
+
}
|
|
68
|
+
if (scope.networkIds) {
|
|
69
|
+
if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
|
|
70
|
+
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
71
|
+
sql += ` AND ${column} IN (${placeholders})`;
|
|
72
|
+
params.push(...scope.networkIds);
|
|
73
|
+
}
|
|
74
|
+
return sql;
|
|
75
|
+
};
|
|
76
|
+
|
|
39
77
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
40
78
|
const params: any[] = [alias];
|
|
41
79
|
let sql = "SELECT status FROM sessions WHERE alias = ?1";
|
|
@@ -74,6 +112,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
74
112
|
},
|
|
75
113
|
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 }) => {
|
|
76
114
|
const effectiveNetId = getNetworkId(netId);
|
|
115
|
+
const sessionNetId = effectiveNetId ?? "default";
|
|
77
116
|
if (!canWrite(effectiveNetId)) {
|
|
78
117
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
79
118
|
}
|
|
@@ -82,11 +121,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
82
121
|
|
|
83
122
|
db.transaction(() => {
|
|
84
123
|
// Only delete same-alias sessions within the same network
|
|
85
|
-
|
|
86
|
-
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
|
|
87
|
-
} else {
|
|
88
|
-
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
|
|
89
|
-
}
|
|
124
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
|
|
90
125
|
db.run(
|
|
91
126
|
`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)
|
|
92
127
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
|
|
@@ -101,7 +136,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
101
136
|
session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
|
|
102
137
|
channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
|
|
103
138
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
104
|
-
[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,
|
|
139
|
+
[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, sessionNetId]
|
|
105
140
|
);
|
|
106
141
|
});
|
|
107
142
|
|
|
@@ -244,7 +279,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
244
279
|
[parentChain.parent_task_id]
|
|
245
280
|
);
|
|
246
281
|
if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
247
|
-
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias });
|
|
282
|
+
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
|
|
248
283
|
}
|
|
249
284
|
}
|
|
250
285
|
} catch (e: any) {
|
|
@@ -266,16 +301,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
266
301
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
267
302
|
},
|
|
268
303
|
async ({ alias, limit }) => {
|
|
269
|
-
const
|
|
304
|
+
const readScope = resolveReadScope(null);
|
|
305
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
270
306
|
const countParams: any[] = [alias];
|
|
271
307
|
let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
272
|
-
countSql =
|
|
308
|
+
countSql = addReadScope(countSql, countParams, readScope);
|
|
273
309
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
274
310
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
275
311
|
const rowsParams: any[] = [alias];
|
|
276
312
|
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
|
|
277
313
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
278
|
-
rowsSql =
|
|
314
|
+
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
279
315
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
280
316
|
LIMIT ?${rowsParams.length + 1}`;
|
|
281
317
|
rowsParams.push(limit);
|
|
@@ -335,19 +371,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
335
371
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
336
372
|
},
|
|
337
373
|
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
338
|
-
const
|
|
339
|
-
|
|
374
|
+
const readScope = resolveReadScope(netId);
|
|
375
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
376
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
340
377
|
|
|
341
378
|
const sessions = db.transaction(() => {
|
|
342
379
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
343
380
|
const staleParams: any[] = [cutoff];
|
|
344
381
|
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
345
|
-
staleSql =
|
|
382
|
+
staleSql = addReadScope(staleSql, staleParams, readScope);
|
|
346
383
|
db.run(staleSql, staleParams);
|
|
347
384
|
|
|
348
385
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
349
386
|
const params: any[] = [];
|
|
350
|
-
|
|
387
|
+
sql = addReadScope(sql, params, readScope);
|
|
351
388
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
352
389
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
353
390
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -356,7 +393,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
356
393
|
|
|
357
394
|
const summaryParams: any[] = [];
|
|
358
395
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
359
|
-
summarySql =
|
|
396
|
+
summarySql = addReadScope(summarySql, summaryParams, readScope);
|
|
360
397
|
summarySql += " GROUP BY status";
|
|
361
398
|
const summary = db.all(summarySql, ...summaryParams);
|
|
362
399
|
|
|
@@ -376,21 +413,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
376
413
|
"Get detailed status of a specific session by alias.",
|
|
377
414
|
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
378
415
|
async ({ alias }) => {
|
|
379
|
-
const
|
|
416
|
+
const readScope = resolveReadScope(null);
|
|
417
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
380
418
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
381
419
|
const sessionParams: any[] = [alias];
|
|
382
420
|
let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
|
|
383
|
-
sessionSql =
|
|
421
|
+
sessionSql = addReadScope(sessionSql, sessionParams, readScope);
|
|
384
422
|
const session = db.get(sessionSql, ...sessionParams);
|
|
385
423
|
|
|
386
424
|
const pendingParams: any[] = [alias];
|
|
387
425
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
388
|
-
pendingSql =
|
|
426
|
+
pendingSql = addReadScope(pendingSql, pendingParams, readScope);
|
|
389
427
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
390
428
|
|
|
391
429
|
const recentParams: any[] = [alias];
|
|
392
430
|
let recentSql = "SELECT * FROM completions WHERE session_name = ?1";
|
|
393
|
-
recentSql =
|
|
431
|
+
recentSql = addReadScope(recentSql, recentParams, readScope);
|
|
394
432
|
recentSql += " ORDER BY completed_at DESC LIMIT 5";
|
|
395
433
|
const recent = db.all(recentSql, ...recentParams);
|
|
396
434
|
|
|
@@ -488,7 +526,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
488
526
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
489
527
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
490
528
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
491
|
-
pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
529
|
+
pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session }, effectiveNetId);
|
|
492
530
|
|
|
493
531
|
return {
|
|
494
532
|
content: [
|
|
@@ -526,7 +564,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
526
564
|
|
|
527
565
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
528
566
|
|
|
529
|
-
pushEvent(alias, { type: "new_message",
|
|
567
|
+
pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
|
|
530
568
|
|
|
531
569
|
return {
|
|
532
570
|
content: [
|
|
@@ -601,7 +639,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
601
639
|
[parentChain.parent_task_id]
|
|
602
640
|
);
|
|
603
641
|
if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
604
|
-
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias });
|
|
642
|
+
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
|
|
605
643
|
}
|
|
606
644
|
}
|
|
607
645
|
} catch (e: any) {
|
|
@@ -610,7 +648,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
610
648
|
}
|
|
611
649
|
|
|
612
650
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
613
|
-
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
651
|
+
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
|
|
614
652
|
|
|
615
653
|
return {
|
|
616
654
|
content: [{
|
|
@@ -687,7 +725,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
687
725
|
});
|
|
688
726
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
689
727
|
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
690
|
-
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
728
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
|
|
691
729
|
return {
|
|
692
730
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
693
731
|
};
|
|
@@ -702,10 +740,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
702
740
|
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
703
741
|
},
|
|
704
742
|
async ({ task_id }) => {
|
|
705
|
-
const
|
|
743
|
+
const readScope = resolveReadScope(null);
|
|
744
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
706
745
|
const params: any[] = [task_id];
|
|
707
746
|
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
708
|
-
sql =
|
|
747
|
+
sql = addReadScope(sql, params, readScope);
|
|
709
748
|
const task = db.get<any>(sql, ...params);
|
|
710
749
|
return {
|
|
711
750
|
content: [{
|
|
@@ -728,10 +767,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
728
767
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
729
768
|
},
|
|
730
769
|
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
731
|
-
const
|
|
770
|
+
const readScope = resolveReadScope(netId);
|
|
771
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
732
772
|
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
733
773
|
const params: any[] = [];
|
|
734
|
-
|
|
774
|
+
sql = addReadScope(sql, params, readScope);
|
|
735
775
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
736
776
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
737
777
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
@@ -742,7 +782,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
742
782
|
// Stats
|
|
743
783
|
const statsParams: any[] = [];
|
|
744
784
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
745
|
-
statsSql =
|
|
785
|
+
statsSql = addReadScope(statsSql, statsParams, readScope);
|
|
746
786
|
statsSql += " GROUP BY status";
|
|
747
787
|
const stats = db.all(statsSql, ...statsParams);
|
|
748
788
|
|
|
@@ -826,7 +866,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
826
866
|
[newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
827
867
|
});
|
|
828
868
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
829
|
-
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
869
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
|
|
830
870
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
831
871
|
}
|
|
832
872
|
);
|
|
@@ -863,7 +903,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
863
903
|
ids.push(id);
|
|
864
904
|
}
|
|
865
905
|
|
|
866
|
-
|
|
906
|
+
for (const t of targets) {
|
|
907
|
+
pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
|
|
908
|
+
}
|
|
867
909
|
|
|
868
910
|
return {
|
|
869
911
|
content: [
|
|
@@ -886,12 +928,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
886
928
|
limit: z.number().min(1).max(500).optional().default(50),
|
|
887
929
|
},
|
|
888
930
|
async ({ since, alias, network_id: netId, limit }) => {
|
|
889
|
-
const
|
|
931
|
+
const readScope = resolveReadScope(netId);
|
|
932
|
+
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
890
933
|
console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
|
|
891
934
|
const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
892
935
|
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
893
936
|
const params: any[] = [cutoff];
|
|
894
|
-
sql =
|
|
937
|
+
sql = addReadScope(sql, params, readScope);
|
|
895
938
|
|
|
896
939
|
if (alias) {
|
|
897
940
|
sql += ` AND session_name = ?${params.length + 1}`;
|