@sleep2agi/commhub-server 0.8.5 → 0.8.6-preview.1

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.8.5",
3
+ "version": "0.8.6-preview.1",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.12.0",
49
- "bun-types": "^1.3.13"
49
+ "bun-types": "^1.3.13",
50
+ "hono": "^4.12.25"
50
51
  }
51
52
  }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ LOGIN_FAILURE_THRESHOLD,
4
+ LOGIN_LOCK_BASE_MS,
5
+ LOGIN_LOCK_MAX_MS,
6
+ LOGIN_IP_MAX_PER_WINDOW,
7
+ LOGIN_IP_WINDOW_MS,
8
+ getLoginClientIp,
9
+ LoginFailureLockout,
10
+ LoginIpRateLimiter,
11
+ normalizeLoginUsername,
12
+ } from "./auth_login_guard";
13
+
14
+ describe("LoginIpRateLimiter", () => {
15
+ test("allows the first N attempts and blocks N+1 within the window", () => {
16
+ const lim = new LoginIpRateLimiter(60_000, 3);
17
+ expect(lim.check("203.0.113.10", 1000).allowed).toBe(true);
18
+ expect(lim.check("203.0.113.10", 1001).allowed).toBe(true);
19
+ expect(lim.check("203.0.113.10", 1002).allowed).toBe(true);
20
+ const blocked = lim.check("203.0.113.10", 1003);
21
+ expect(blocked.allowed).toBe(false);
22
+ expect(blocked.retryAfterMs).toBeGreaterThan(0);
23
+ });
24
+
25
+ test("default public IP limit is 10/min", () => {
26
+ expect(LOGIN_IP_WINDOW_MS).toBe(60_000);
27
+ expect(LOGIN_IP_MAX_PER_WINDOW).toBe(10);
28
+ });
29
+
30
+ test("evicts expired entries before enforcing the cap", () => {
31
+ const lim = new LoginIpRateLimiter(100, 10, 2);
32
+ expect(lim.check("192.0.2.1", 0).allowed).toBe(true);
33
+ expect(lim.check("192.0.2.2", 0).allowed).toBe(true);
34
+ expect(lim.check("192.0.2.3", 101).allowed).toBe(true);
35
+
36
+ expect(lim.check("192.0.2.1", 102).remaining).toBe(9);
37
+ });
38
+
39
+ test("evicts the oldest active entry when the cap is full", () => {
40
+ const lim = new LoginIpRateLimiter(60_000, 1, 2);
41
+ expect(lim.check("192.0.2.1", 0).allowed).toBe(true);
42
+ expect(lim.check("192.0.2.2", 1).allowed).toBe(true);
43
+ expect(lim.check("192.0.2.3", 2).allowed).toBe(true);
44
+
45
+ expect(lim.check("192.0.2.1", 3).allowed).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("LoginFailureLockout", () => {
50
+ test("locks on the fifth failure and doubles subsequent lock durations", () => {
51
+ const lockout = new LoginFailureLockout(5, 30_000, 900_000);
52
+ const t0 = 1_700_000_000_000;
53
+ for (let i = 0; i < 4; i++) {
54
+ const r = lockout.recordFailure("Alice", t0 + i);
55
+ expect(r.locked).toBe(false);
56
+ expect(r.failures).toBe(i + 1);
57
+ }
58
+
59
+ const first = lockout.recordFailure(" alice ", t0 + 5);
60
+ expect(first.locked).toBe(true);
61
+ expect(first.lockMs).toBe(30_000);
62
+ expect(lockout.check("ALICE", t0 + 6).locked).toBe(true);
63
+
64
+ const second = lockout.recordFailure("alice", t0 + 30_006);
65
+ expect(second.locked).toBe(true);
66
+ expect(second.lockMs).toBe(60_000);
67
+ });
68
+
69
+ test("caps exponential lock duration and success clears failure state", () => {
70
+ const lockout = new LoginFailureLockout(2, 1000, 4000);
71
+ const t0 = 10_000;
72
+ expect(lockout.recordFailure("bob", t0).locked).toBe(false);
73
+ expect(lockout.recordFailure("bob", t0 + 1).lockMs).toBe(1000);
74
+ expect(lockout.recordFailure("bob", t0 + 1001).lockMs).toBe(2000);
75
+ expect(lockout.recordFailure("bob", t0 + 3002).lockMs).toBe(4000);
76
+ expect(lockout.recordFailure("bob", t0 + 7003).lockMs).toBe(4000);
77
+
78
+ lockout.recordSuccess("bob");
79
+ expect(lockout.check("bob", t0 + 7004).locked).toBe(false);
80
+ expect(lockout.recordFailure("bob", t0 + 7005).locked).toBe(false);
81
+ });
82
+
83
+ test("default failure policy is 5 failures, 30s base, 15min cap", () => {
84
+ expect(LOGIN_FAILURE_THRESHOLD).toBe(5);
85
+ expect(LOGIN_LOCK_BASE_MS).toBe(30_000);
86
+ expect(LOGIN_LOCK_MAX_MS).toBe(15 * 60_000);
87
+ });
88
+
89
+ test("evicts expired failure entries before enforcing the cap", () => {
90
+ const lockout = new LoginFailureLockout(2, 100, 1000, 2);
91
+ expect(lockout.recordFailure("alice", 0).locked).toBe(false);
92
+ expect(lockout.recordFailure("bob", 0).locked).toBe(false);
93
+ expect(lockout.recordFailure("carol", 1).locked).toBe(false);
94
+
95
+ expect(lockout.recordFailure("alice", 2).locked).toBe(false);
96
+ });
97
+
98
+ test("evicts the oldest active failure entry when the cap is full", () => {
99
+ const lockout = new LoginFailureLockout(2, 1000, 1000, 2);
100
+ expect(lockout.recordFailure("alice", 0).locked).toBe(false);
101
+ expect(lockout.recordFailure("bob", 1).locked).toBe(false);
102
+ expect(lockout.recordFailure("carol", 2).locked).toBe(false);
103
+
104
+ expect(lockout.recordFailure("alice", 3).locked).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("normalizeLoginUsername", () => {
109
+ test("normalizes for case-insensitive lockout keys", () => {
110
+ expect(normalizeLoginUsername(" Alice ")).toBe("alice");
111
+ expect(normalizeLoginUsername(undefined)).toBe("");
112
+ });
113
+ });
114
+
115
+ describe("getLoginClientIp", () => {
116
+ test("uses the last x-forwarded-for hop so spoofed first hops do not bypass login rate limits", () => {
117
+ const req = new Request("http://127.0.0.1/api/auth/login", {
118
+ headers: { "x-forwarded-for": "198.51.100.99, 203.0.113.10" },
119
+ });
120
+ expect(getLoginClientIp(req)).toBe("203.0.113.10");
121
+ });
122
+
123
+ test("falls back to x-real-ip, then unknown", () => {
124
+ const withRealIp = new Request("http://127.0.0.1/api/auth/login", {
125
+ headers: { "x-real-ip": "203.0.113.20" },
126
+ });
127
+ expect(getLoginClientIp(withRealIp)).toBe("203.0.113.20");
128
+ expect(getLoginClientIp(new Request("http://127.0.0.1/api/auth/login"))).toBe("unknown");
129
+ });
130
+ });
@@ -0,0 +1,142 @@
1
+ // #226 — public login brute-force guard.
2
+ // In-memory by design: zero dependency, process-local defence-in-depth for the
3
+ // public /api/auth/login surface. Bearer token auth paths do not use this file.
4
+
5
+ export const LOGIN_IP_WINDOW_MS = 60_000;
6
+ export const LOGIN_IP_MAX_PER_WINDOW = 10;
7
+ export const LOGIN_FAILURE_THRESHOLD = 5;
8
+ export const LOGIN_LOCK_BASE_MS = 30_000;
9
+ export const LOGIN_LOCK_MAX_MS = 15 * 60_000;
10
+ export const LOGIN_GUARD_MAX_ENTRIES = 50_000;
11
+
12
+ type WindowState = { count: number; resetAt: number };
13
+
14
+ export class LoginIpRateLimiter {
15
+ private state = new Map<string, WindowState>();
16
+
17
+ constructor(
18
+ private readonly windowMs: number = LOGIN_IP_WINDOW_MS,
19
+ private readonly maxPerWindow: number = LOGIN_IP_MAX_PER_WINDOW,
20
+ private readonly maxEntries: number = LOGIN_GUARD_MAX_ENTRIES,
21
+ ) {}
22
+
23
+ check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
24
+ if (this.state.size >= this.maxEntries && !this.state.has(key)) {
25
+ pruneExpired(this.state, nowMs, (entry) => nowMs >= entry.resetAt);
26
+ evictOldestUntilBelowCap(this.state, this.maxEntries);
27
+ }
28
+ const entry = this.state.get(key);
29
+ if (!entry || nowMs >= entry.resetAt) {
30
+ const resetAt = nowMs + this.windowMs;
31
+ this.state.set(key, { count: 1, resetAt });
32
+ return { allowed: true, remaining: this.maxPerWindow - 1, resetAt };
33
+ }
34
+ if (entry.count >= this.maxPerWindow) {
35
+ return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfterMs: entry.resetAt - nowMs };
36
+ }
37
+ entry.count++;
38
+ return { allowed: true, remaining: this.maxPerWindow - entry.count, resetAt: entry.resetAt };
39
+ }
40
+
41
+ reset(): void {
42
+ this.state.clear();
43
+ }
44
+ }
45
+
46
+ type FailureState = { failures: number; lockedUntil: number };
47
+
48
+ export class LoginFailureLockout {
49
+ private state = new Map<string, FailureState>();
50
+
51
+ constructor(
52
+ private readonly threshold: number = LOGIN_FAILURE_THRESHOLD,
53
+ private readonly baseLockMs: number = LOGIN_LOCK_BASE_MS,
54
+ private readonly maxLockMs: number = LOGIN_LOCK_MAX_MS,
55
+ private readonly maxEntries: number = LOGIN_GUARD_MAX_ENTRIES,
56
+ ) {}
57
+
58
+ check(username: string, nowMs: number = Date.now()): { locked: boolean; retryAfterMs?: number; lockedUntil?: number } {
59
+ const key = normalizeLoginUsername(username);
60
+ if (!key) return { locked: false };
61
+ const entry = this.state.get(key);
62
+ if (!entry || !entry.lockedUntil || nowMs >= entry.lockedUntil) return { locked: false };
63
+ return { locked: true, retryAfterMs: entry.lockedUntil - nowMs, lockedUntil: entry.lockedUntil };
64
+ }
65
+
66
+ recordFailure(username: string, nowMs: number = Date.now()): { locked: boolean; failures: number; lockMs?: number; lockedUntil?: number } {
67
+ const key = normalizeLoginUsername(username);
68
+ if (!key) return { locked: false, failures: 0 };
69
+ if (this.state.size >= this.maxEntries && !this.state.has(key)) {
70
+ pruneExpired(this.state, nowMs, (entry) => !entry.lockedUntil || nowMs >= entry.lockedUntil);
71
+ evictOldestUntilBelowCap(this.state, this.maxEntries);
72
+ }
73
+ const current = this.state.get(key);
74
+ const failures = (current?.failures ?? 0) + 1;
75
+ if (failures < this.threshold) {
76
+ this.state.set(key, { failures, lockedUntil: 0 });
77
+ return { locked: false, failures };
78
+ }
79
+
80
+ const exponent = failures - this.threshold;
81
+ const lockMs = Math.min(this.baseLockMs * Math.pow(2, exponent), this.maxLockMs);
82
+ const lockedUntil = nowMs + lockMs;
83
+ this.state.set(key, { failures, lockedUntil });
84
+ return { locked: true, failures, lockMs, lockedUntil };
85
+ }
86
+
87
+ recordSuccess(username: string): void {
88
+ const key = normalizeLoginUsername(username);
89
+ if (key) this.state.delete(key);
90
+ }
91
+
92
+ reset(): void {
93
+ this.state.clear();
94
+ }
95
+ }
96
+
97
+ export function normalizeLoginUsername(username: unknown): string {
98
+ return typeof username === "string" ? username.trim().toLowerCase() : "";
99
+ }
100
+
101
+ export function getLoginClientIp(req: Request): string {
102
+ const fwd = req.headers.get("x-forwarded-for");
103
+ if (fwd) {
104
+ const last = fwd.split(",").map((s) => s.trim()).filter(Boolean).at(-1);
105
+ if (last) return last;
106
+ }
107
+ return req.headers.get("x-real-ip")?.trim() || "unknown";
108
+ }
109
+
110
+ function pruneExpired<T>(map: Map<string, T>, nowMs: number, expired: (entry: T, nowMs: number) => boolean): void {
111
+ for (const [key, entry] of map) {
112
+ if (expired(entry, nowMs)) map.delete(key);
113
+ }
114
+ }
115
+
116
+ function evictOldestUntilBelowCap<T>(map: Map<string, T>, maxEntries: number): void {
117
+ while (map.size >= maxEntries) {
118
+ const oldest = map.keys().next().value as string | undefined;
119
+ if (!oldest) return;
120
+ map.delete(oldest);
121
+ }
122
+ }
123
+
124
+ function envInt(name: string, fallback: number): number {
125
+ const raw = process.env[name];
126
+ if (!raw) return fallback;
127
+ const n = Number(raw);
128
+ return Number.isFinite(n) && n > 0 ? n : fallback;
129
+ }
130
+
131
+ export const sharedLoginIpRateLimiter = new LoginIpRateLimiter(
132
+ envInt("COMMHUB_LOGIN_IP_WINDOW_MS", LOGIN_IP_WINDOW_MS),
133
+ envInt("COMMHUB_LOGIN_IP_MAX", LOGIN_IP_MAX_PER_WINDOW),
134
+ envInt("COMMHUB_LOGIN_GUARD_MAX_ENTRIES", LOGIN_GUARD_MAX_ENTRIES),
135
+ );
136
+
137
+ export const sharedLoginFailureLockout = new LoginFailureLockout(
138
+ envInt("COMMHUB_LOGIN_FAILURE_THRESHOLD", LOGIN_FAILURE_THRESHOLD),
139
+ envInt("COMMHUB_LOGIN_LOCK_BASE_MS", LOGIN_LOCK_BASE_MS),
140
+ envInt("COMMHUB_LOGIN_LOCK_MAX_MS", LOGIN_LOCK_MAX_MS),
141
+ envInt("COMMHUB_LOGIN_GUARD_MAX_ENTRIES", LOGIN_GUARD_MAX_ENTRIES),
142
+ );
package/src/db.ts CHANGED
@@ -123,13 +123,14 @@ db.exec(`
123
123
  ON agent_telemetry(network_id, alias, created_at);
124
124
  `);
125
125
 
126
- // inbox: add in_reply_to, requires_response, expires_at, scope
126
+ // inbox: add in_reply_to, requires_response, expires_at, scope, node identity
127
127
  for (const col of [
128
128
  { name: "in_reply_to", def: "TEXT" },
129
129
  { name: "requires_response", def: "TEXT DEFAULT 'reply'" },
130
130
  { name: "expires_at", def: "TEXT" },
131
131
  { name: "scope", def: "TEXT DEFAULT 'single'" },
132
132
  { name: "meta_json", def: "TEXT" },
133
+ { name: "node_id", def: "TEXT" },
133
134
  ]) {
134
135
  try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
135
136
  }
@@ -138,6 +139,7 @@ for (const col of [
138
139
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_type ON inbox(type)"); } catch {}
139
140
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_from ON inbox(from_session)"); } catch {}
140
141
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_reply ON inbox(in_reply_to)"); } catch {}
142
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
141
143
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_node ON sessions(node_id)"); } catch {}
142
144
 
143
145
  // tasks table (V2)
@@ -384,6 +386,185 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
384
386
  try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
385
387
  }
386
388
 
389
+ // PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
390
+ // existing databases created `tasks` before from_node_id/to_node_id were in the
391
+ // CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
392
+ for (const col of [
393
+ { table: "tasks", name: "from_node_id", def: "TEXT" },
394
+ { table: "tasks", name: "to_node_id", def: "TEXT" },
395
+ { table: "inbox", name: "node_id", def: "TEXT" },
396
+ ]) {
397
+ try { db.exec(`ALTER TABLE ${col.table} ADD COLUMN ${col.name} ${col.def}`); } catch {}
398
+ }
399
+
400
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_from_node ON tasks(from_node_id)"); } catch {}
401
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_to_node ON tasks(to_node_id)"); } catch {}
402
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
403
+
404
+ function backfillMessageNodeIds() {
405
+ let inboxDirect = 0;
406
+ let inboxRenamed = 0;
407
+ let tasksToDirect = 0;
408
+ let tasksToRenamed = 0;
409
+ let tasksFromDirect = 0;
410
+ let tasksFromRenamed = 0;
411
+
412
+ try {
413
+ inboxDirect = db.run(`
414
+ UPDATE inbox
415
+ SET node_id = (
416
+ SELECT s.node_id FROM sessions s
417
+ WHERE s.node_id IS NOT NULL
418
+ AND s.alias = inbox.session_name
419
+ AND COALESCE(s.network_id, 'default') = COALESCE(inbox.network_id, 'default')
420
+ ORDER BY s.updated_at DESC
421
+ LIMIT 1
422
+ )
423
+ WHERE node_id IS NULL
424
+ AND EXISTS (
425
+ SELECT 1 FROM sessions s
426
+ WHERE s.node_id IS NOT NULL
427
+ AND s.alias = inbox.session_name
428
+ AND COALESCE(s.network_id, 'default') = COALESCE(inbox.network_id, 'default')
429
+ )
430
+ `).changes;
431
+ } catch {}
432
+
433
+ try {
434
+ inboxRenamed = db.run(`
435
+ UPDATE inbox
436
+ SET node_id = (
437
+ SELECT s.node_id FROM rename_txn r
438
+ JOIN sessions s
439
+ ON s.alias = r.new_alias
440
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
441
+ WHERE s.node_id IS NOT NULL
442
+ AND r.status = 'committed'
443
+ AND r.old_alias = inbox.session_name
444
+ AND COALESCE(r.network_id, 'default') = COALESCE(inbox.network_id, 'default')
445
+ ORDER BY r.committed_at DESC
446
+ LIMIT 1
447
+ )
448
+ WHERE node_id IS NULL
449
+ AND EXISTS (
450
+ SELECT 1 FROM rename_txn r
451
+ JOIN sessions s
452
+ ON s.alias = r.new_alias
453
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
454
+ WHERE s.node_id IS NOT NULL
455
+ AND r.status = 'committed'
456
+ AND r.old_alias = inbox.session_name
457
+ AND COALESCE(r.network_id, 'default') = COALESCE(inbox.network_id, 'default')
458
+ )
459
+ `).changes;
460
+ } catch {}
461
+
462
+ try {
463
+ tasksToDirect = db.run(`
464
+ UPDATE tasks
465
+ SET to_node_id = (
466
+ SELECT s.node_id FROM sessions s
467
+ WHERE s.node_id IS NOT NULL
468
+ AND s.alias = tasks.to_name
469
+ AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
470
+ ORDER BY s.updated_at DESC
471
+ LIMIT 1
472
+ )
473
+ WHERE to_node_id IS NULL
474
+ AND EXISTS (
475
+ SELECT 1 FROM sessions s
476
+ WHERE s.node_id IS NOT NULL
477
+ AND s.alias = tasks.to_name
478
+ AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
479
+ )
480
+ `).changes;
481
+ } catch {}
482
+
483
+ try {
484
+ tasksToRenamed = db.run(`
485
+ UPDATE tasks
486
+ SET to_node_id = (
487
+ SELECT s.node_id FROM rename_txn r
488
+ JOIN sessions s
489
+ ON s.alias = r.new_alias
490
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
491
+ WHERE s.node_id IS NOT NULL
492
+ AND r.status = 'committed'
493
+ AND r.old_alias = tasks.to_name
494
+ AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
495
+ ORDER BY r.committed_at DESC
496
+ LIMIT 1
497
+ )
498
+ WHERE to_node_id IS NULL
499
+ AND EXISTS (
500
+ SELECT 1 FROM rename_txn r
501
+ JOIN sessions s
502
+ ON s.alias = r.new_alias
503
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
504
+ WHERE s.node_id IS NOT NULL
505
+ AND r.status = 'committed'
506
+ AND r.old_alias = tasks.to_name
507
+ AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
508
+ )
509
+ `).changes;
510
+ } catch {}
511
+
512
+ try {
513
+ tasksFromDirect = db.run(`
514
+ UPDATE tasks
515
+ SET from_node_id = (
516
+ SELECT s.node_id FROM sessions s
517
+ WHERE s.node_id IS NOT NULL
518
+ AND s.alias = tasks.from_name
519
+ AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
520
+ ORDER BY s.updated_at DESC
521
+ LIMIT 1
522
+ )
523
+ WHERE from_node_id IS NULL
524
+ AND EXISTS (
525
+ SELECT 1 FROM sessions s
526
+ WHERE s.node_id IS NOT NULL
527
+ AND s.alias = tasks.from_name
528
+ AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
529
+ )
530
+ `).changes;
531
+ } catch {}
532
+
533
+ try {
534
+ tasksFromRenamed = db.run(`
535
+ UPDATE tasks
536
+ SET from_node_id = (
537
+ SELECT s.node_id FROM rename_txn r
538
+ JOIN sessions s
539
+ ON s.alias = r.new_alias
540
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
541
+ WHERE s.node_id IS NOT NULL
542
+ AND r.status = 'committed'
543
+ AND r.old_alias = tasks.from_name
544
+ AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
545
+ ORDER BY r.committed_at DESC
546
+ LIMIT 1
547
+ )
548
+ WHERE from_node_id IS NULL
549
+ AND EXISTS (
550
+ SELECT 1 FROM rename_txn r
551
+ JOIN sessions s
552
+ ON s.alias = r.new_alias
553
+ AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
554
+ WHERE s.node_id IS NOT NULL
555
+ AND r.status = 'committed'
556
+ AND r.old_alias = tasks.from_name
557
+ AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
558
+ )
559
+ `).changes;
560
+ } catch {}
561
+
562
+ const total = inboxDirect + inboxRenamed + tasksToDirect + tasksToRenamed + tasksFromDirect + tasksFromRenamed;
563
+ console.log(`[commhub] node_id backfill: inbox=${inboxDirect + inboxRenamed}, tasks.to=${tasksToDirect + tasksToRenamed}, tasks.from=${tasksFromDirect + tasksFromRenamed}, total=${total}`);
564
+ }
565
+
566
+ backfillMessageNodeIds();
567
+
387
568
  // ── P0: sessions alias uniqueness is network-scoped.
388
569
  // Older SQLite databases created `alias TEXT UNIQUE`, which prevents the same
389
570
  // agent alias from existing in two networks. SQLite cannot drop a UNIQUE
@@ -588,10 +769,14 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
588
769
  if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
589
770
  try {
590
771
  const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
772
+ const notifyNode = db.get<{ node_id: string | null }>(
773
+ "SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
774
+ [parent.from_name, parent.network_id ?? null]
775
+ );
591
776
  db.run(
592
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
593
- VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
594
- [notifyId, parent.from_name, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
777
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
778
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
779
+ [notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
595
780
  );
596
781
  } catch {}
597
782
  }
@@ -0,0 +1,10 @@
1
+ import { EventEmitter } from "events";
2
+
3
+ export type RenameCommittedEvent = {
4
+ networkId: string | null;
5
+ old_alias: string;
6
+ new_alias: string;
7
+ node_id?: string | null;
8
+ };
9
+
10
+ export const eventBus = new EventEmitter();