@sleep2agi/commhub-server 0.8.6-preview.0 → 0.8.6

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.6-preview.0",
3
+ "version": "0.8.6",
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();
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
7
7
  import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
9
9
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
10
+ import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
10
11
  import {
11
12
  FILE_ID_REGEX,
12
13
  MAX_REQUEST_CONTENT_LENGTH,
@@ -364,7 +365,7 @@ type RestDeliveryTarget =
364
365
 
365
366
  function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
366
367
  const params: any[] = [alias];
367
- let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
368
+ let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
368
369
  if (networkId) {
369
370
  sql += " AND network_id = ?2";
370
371
  params.push(networkId);
@@ -387,6 +388,13 @@ function resolveRestDeliveryTarget(alias: string, networkId: string | null): Res
387
388
  return { state: "online", alias, session };
388
389
  }
389
390
 
391
+ function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
392
+ if (!alias || alias === "hub" || alias === "api") return null;
393
+ const canonical = resolveCanonicalAlias(networkId, alias);
394
+ const target = resolveRestDeliveryTarget(canonical.alias, networkId);
395
+ return target.state === "not_found" ? null : (target.session?.node_id ?? null);
396
+ }
397
+
390
398
  // ── REST input schema ───────────────────────────────
391
399
  const TaskSchema = z.object({
392
400
  alias: z.string().min(1).max(200),
@@ -605,16 +613,37 @@ Bun.serve({
605
613
  }
606
614
 
607
615
  if (url.pathname === "/api/auth/login" && req.method === "POST") {
608
- const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
609
- if (!checkRateLimit(clientIP, 10)) {
616
+ const clientIP = getLoginClientIp(req);
617
+ const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
618
+ if (!ipRate.allowed) {
610
619
  logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
611
- return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
620
+ return withCors(req, Response.json(
621
+ { ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
622
+ { status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
623
+ ));
612
624
  }
613
625
  try {
614
626
  const body = await req.json() as any;
627
+ const lock = sharedLoginFailureLockout.check(body.username);
628
+ if (lock.locked) {
629
+ logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
630
+ return withCors(req, Response.json(
631
+ { ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
632
+ { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
633
+ ));
634
+ }
615
635
  const result = login(body.username, body.password);
616
- if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
617
- else logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
636
+ if (result.ok) {
637
+ sharedLoginFailureLockout.recordSuccess(body.username);
638
+ logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
639
+ } else {
640
+ const failure = sharedLoginFailureLockout.recordFailure(body.username);
641
+ if (failure.locked) {
642
+ const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
643
+ console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
644
+ }
645
+ logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
646
+ }
618
647
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
619
648
  } catch (e: any) {
620
649
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
@@ -706,7 +735,7 @@ Bun.serve({
706
735
  }
707
736
  const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
708
737
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
709
- return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
738
+ return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
710
739
  } catch (e: any) {
711
740
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
712
741
  }
@@ -984,37 +1013,60 @@ Bun.serve({
984
1013
  staleSql = addNetworkScope(staleSql, staleParams, restScope);
985
1014
  db.run(staleSql, staleParams);
986
1015
  cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
1016
+ // `?light=1` returns a narrow projection (alias / status / agent / task /
1017
+ // server / updated_at + runtime + network_id) — used by the mobile APP
1018
+ // list view, where pulling 30+ fields × 150 agents was making cold open
1019
+ // take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
1020
+ // is unchanged so the dashboard / scripts that read full telemetry are
1021
+ // unaffected.
1022
+ const isLight = url.searchParams.get("light") === "1";
987
1023
  const params: any[] = [];
988
- let sql = "SELECT * FROM sessions WHERE 1=1";
1024
+ let sql = isLight
1025
+ ? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
1026
+ : "SELECT * FROM sessions WHERE 1=1";
989
1027
  sql = addNetworkScope(sql, params, restScope);
990
1028
  sql += " ORDER BY updated_at DESC";
991
1029
  // `model` comes straight from the sessions row (SELECT *); `runtime` is
992
1030
  // derived from the raw `agent` field. Both default to null for old nodes
993
1031
  // that never reported a model — the dashboard falls back to a placeholder.
994
- const sessions = db.all(sql, ...params).map((s: any) => ({
995
- ...s,
996
- model: s.model ?? null,
997
- runtime: normalizeRuntime(s.agent),
998
- host: {
999
- hostname: s.hostname ?? null,
1000
- ip: s.ip ?? null,
1001
- cpu_load_1min: s.cpu_load_1min ?? null,
1002
- cpu_cores: s.cpu_cores ?? null,
1003
- mem_total_gb: s.mem_total_gb ?? null,
1004
- mem_used_gb: s.mem_used_gb ?? null,
1005
- mem_avail_gb: s.mem_avail_gb ?? null,
1006
- disk_total_gb: s.disk_total_gb ?? null,
1007
- disk_used_gb: s.disk_used_gb ?? null,
1008
- disk_avail_gb: s.disk_avail_gb ?? null,
1009
- },
1010
- process_telemetry: {
1011
- rss_bytes: s.process_rss_bytes ?? null,
1012
- rss_mb: s.process_rss_mb ?? null,
1013
- cpu_pct: s.process_cpu_pct ?? null,
1014
- uptime_seconds: s.process_uptime_seconds ?? null,
1015
- in_flight_count: s.process_in_flight_count ?? null,
1016
- },
1017
- }));
1032
+ const sessions = db.all(sql, ...params).map((s: any) => {
1033
+ if (isLight) {
1034
+ return {
1035
+ alias: s.alias,
1036
+ status: s.status,
1037
+ agent: s.agent ?? null,
1038
+ task: s.task ?? null,
1039
+ server: s.server ?? null,
1040
+ updated_at: s.updated_at ?? null,
1041
+ runtime: normalizeRuntime(s.agent),
1042
+ network_id: s.network_id ?? null,
1043
+ };
1044
+ }
1045
+ return {
1046
+ ...s,
1047
+ model: s.model ?? null,
1048
+ runtime: normalizeRuntime(s.agent),
1049
+ host: {
1050
+ hostname: s.hostname ?? null,
1051
+ ip: s.ip ?? null,
1052
+ cpu_load_1min: s.cpu_load_1min ?? null,
1053
+ cpu_cores: s.cpu_cores ?? null,
1054
+ mem_total_gb: s.mem_total_gb ?? null,
1055
+ mem_used_gb: s.mem_used_gb ?? null,
1056
+ mem_avail_gb: s.mem_avail_gb ?? null,
1057
+ disk_total_gb: s.disk_total_gb ?? null,
1058
+ disk_used_gb: s.disk_used_gb ?? null,
1059
+ disk_avail_gb: s.disk_avail_gb ?? null,
1060
+ },
1061
+ process_telemetry: {
1062
+ rss_bytes: s.process_rss_bytes ?? null,
1063
+ rss_mb: s.process_rss_mb ?? null,
1064
+ cpu_pct: s.process_cpu_pct ?? null,
1065
+ uptime_seconds: s.process_uptime_seconds ?? null,
1066
+ in_flight_count: s.process_in_flight_count ?? null,
1067
+ },
1068
+ };
1069
+ });
1018
1070
  const summary = sessions.reduce((acc: any, session: any) => {
1019
1071
  const raw = String(session.status || "").toLowerCase();
1020
1072
  if (raw === "offline") acc.offline++;
@@ -1486,6 +1538,8 @@ Bun.serve({
1486
1538
  const id = crypto.randomUUID();
1487
1539
  const fromSession = body.from || "api";
1488
1540
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1541
+ const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
1542
+ const targetNodeId = target.session?.node_id ?? null;
1489
1543
  // #221 — fold top-level `attachments` into `meta.attachments` so
1490
1544
  // the REST and MCP send_task transports produce identical
1491
1545
  // tasks.meta_json shape downstream. Top-level wins over any
@@ -1525,14 +1579,14 @@ Bun.serve({
1525
1579
  // dispatched via REST (anet demo, dashboard Dispatch button, etc.).
1526
1580
  db.transaction(() => {
1527
1581
  db.run(
1528
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
1529
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6, ?7)`,
1530
- [id, targetAlias, body.priority, body.task, fromSession, taskNetId, metaJson]
1582
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
1583
+ VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
1584
+ [id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
1531
1585
  );
1532
1586
  db.run(
1533
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
1534
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
1535
- [id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
1587
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
1588
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
1589
+ [id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
1536
1590
  );
1537
1591
  // Touch session row so the dashboard reflects "task in flight"
1538
1592
  // immediately, without waiting for the agent's report_status to
@@ -1590,19 +1644,19 @@ Bun.serve({
1590
1644
  if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
1591
1645
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1592
1646
  }
1593
- let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
1647
+ let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
1594
1648
  const params: any[] = [];
1595
1649
  sql = addNetworkScope(sql, params, restScope);
1596
1650
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
1597
1651
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
1598
- const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
1652
+ const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1599
1653
  const ids: string[] = [];
1600
1654
  for (const t of targets) {
1601
1655
  const id = crypto.randomUUID();
1602
1656
  db.run(
1603
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
1604
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
1605
- [id, t.alias, body.message, t.network_id]
1657
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1658
+ VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
1659
+ [id, t.alias, t.node_id ?? null, body.message, t.network_id]
1606
1660
  );
1607
1661
  ids.push(id);
1608
1662
  }
@@ -0,0 +1,47 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { eventBus } from "./event_bus";
3
+ import { __resetSSEClientsForTest, createSSEStream, getSSEStats } from "./push";
4
+
5
+ afterEach(() => {
6
+ __resetSSEClientsForTest();
7
+ });
8
+
9
+ test("rename-committed rekeys existing SSE clients from old alias to new alias (case 8)", async () => {
10
+ const res = createSSEStream("old-agent", "net-rekey-8");
11
+ const reader = res.body!.getReader();
12
+
13
+ expect(getSSEStats().sessions["net-rekey-8:old-agent"]).toBe(1);
14
+ eventBus.emit("rename-committed", {
15
+ networkId: "net-rekey-8",
16
+ old_alias: "old-agent",
17
+ new_alias: "new-agent",
18
+ node_id: "node-8",
19
+ });
20
+
21
+ const stats = getSSEStats().sessions;
22
+ expect(stats["net-rekey-8:old-agent"]).toBeUndefined();
23
+ expect(stats["net-rekey-8:new-agent"]).toBe(1);
24
+ await reader.cancel();
25
+ });
26
+
27
+ test("rename-committed merges old and new SSE client buckets without dropping clients (case 11)", async () => {
28
+ const oldRes = createSSEStream("old-agent", "net-rekey-11");
29
+ const newRes = createSSEStream("new-agent", "net-rekey-11");
30
+ const oldReader = oldRes.body!.getReader();
31
+ const newReader = newRes.body!.getReader();
32
+
33
+ expect(getSSEStats().sessions["net-rekey-11:old-agent"]).toBe(1);
34
+ expect(getSSEStats().sessions["net-rekey-11:new-agent"]).toBe(1);
35
+ eventBus.emit("rename-committed", {
36
+ networkId: "net-rekey-11",
37
+ old_alias: "old-agent",
38
+ new_alias: "new-agent",
39
+ node_id: "node-11",
40
+ });
41
+
42
+ const stats = getSSEStats().sessions;
43
+ expect(stats["net-rekey-11:old-agent"]).toBeUndefined();
44
+ expect(stats["net-rekey-11:new-agent"]).toBe(2);
45
+ await oldReader.cancel();
46
+ await newReader.cancel();
47
+ });
package/src/push.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // Agent 连 GET /events/:session → 保持 SSE 长连接
3
3
  // send_task 写 inbox 后 → pushEvent() → 秒达
4
4
 
5
+ import { eventBus, type RenameCommittedEvent } from "./event_bus";
6
+
5
7
  type SSEClient = {
6
8
  controller: ReadableStreamDefaultController;
7
9
  encoder: TextEncoder;
@@ -72,6 +74,28 @@ function clientKey(sessionName: string, networkId?: string | null): string {
72
74
  return `${networkId || "global"}:${sessionName}`;
73
75
  }
74
76
 
77
+ function rekeyClient(oldSessionName: string, newSessionName: string, networkId?: string | null): number {
78
+ if (!oldSessionName || !newSessionName || oldSessionName === newSessionName) return 0;
79
+ const oldKey = clientKey(oldSessionName, networkId);
80
+ const newKey = clientKey(newSessionName, networkId);
81
+ const oldClients = clients.get(oldKey);
82
+ if (!oldClients || oldClients.length === 0) return 0;
83
+
84
+ const existing = clients.get(newKey) || [];
85
+ clients.set(newKey, existing.concat(oldClients));
86
+ clients.delete(oldKey);
87
+ console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
88
+ return oldClients.length;
89
+ }
90
+
91
+ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
92
+ try {
93
+ rekeyClient(event.old_alias, event.new_alias, event.networkId);
94
+ } catch (e: any) {
95
+ console.log(`[${ts()}] SSE rekey failed: ${e?.message || e}`);
96
+ }
97
+ });
98
+
75
99
  /** 推送事件给指定 session 的所有 SSE 连接 */
76
100
  export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
77
101
  const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
@@ -95,6 +119,10 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
95
119
  if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
96
120
  }
97
121
 
122
+ export function __resetSSEClientsForTest(): void {
123
+ clients.clear();
124
+ }
125
+
98
126
  /** 获取当前 SSE 连接统计 */
99
127
  export function getSSEStats(): { total: number; sessions: Record<string, number> } {
100
128
  let total = 0;
package/src/rename.ts CHANGED
@@ -16,11 +16,14 @@ import { randomUUID } from "crypto";
16
16
  import { db } from "./db";
17
17
  import { getUserNetworkRole, getNetworkMembers } from "./auth";
18
18
  import { pushEvent } from "./push";
19
+ import { eventBus } from "./event_bus";
19
20
 
20
21
  export interface RenameResult {
21
22
  ok: boolean;
22
23
  txn_id?: string;
23
24
  error?: string;
25
+ code?: string;
26
+ suggested?: string;
24
27
  }
25
28
 
26
29
  export interface CanonicalAlias {
@@ -111,7 +114,14 @@ export function prepareRename(
111
114
  // old-alias must exist as a session in this network
112
115
  const oldSession = db.get<any>(
113
116
  "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, oldAlias);
114
- if (!oldSession) return { ok: false, error: `node "${oldAlias}" not found in this network` };
117
+ if (!oldSession) {
118
+ return {
119
+ ok: false,
120
+ code: "node_local_only",
121
+ error: `node "${oldAlias}" has no server session in this network`,
122
+ suggested: "rename locally",
123
+ };
124
+ }
115
125
 
116
126
  // new-alias must not be taken — in sessions OR reserved by another prepared txn
117
127
  const newInSessions = db.get<any>(
@@ -147,19 +157,32 @@ export function commitRename(userId: string, txnId: string): RenameResult {
147
157
  "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
148
158
  if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
149
159
 
150
- db.run(
151
- "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
152
- [txn.new_alias, txn.network_id, txn.old_alias]);
153
- cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
154
- db.run(
155
- "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
156
- [txn.new_alias, txn.network_id, txn.old_alias]);
157
- db.run(
158
- "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
159
- [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
160
- db.run(
161
- "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
162
- [txnId]);
160
+ const renamedNode = db.transaction(() => {
161
+ db.run(
162
+ "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
163
+ [txn.new_alias, txn.network_id, txn.old_alias]);
164
+ cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
165
+ db.run(
166
+ "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
167
+ [txn.new_alias, txn.network_id, txn.old_alias]);
168
+ db.run(
169
+ "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
170
+ [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
171
+ db.run(
172
+ "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
173
+ [txnId]);
174
+ const node = db.get<{ node_id: string | null }>(
175
+ "SELECT node_id FROM sessions WHERE network_id = ?1 AND alias = ?2",
176
+ [txn.network_id, txn.new_alias]
177
+ );
178
+ eventBus.emit("rename-committed", {
179
+ networkId: txn.network_id,
180
+ old_alias: txn.old_alias,
181
+ new_alias: txn.new_alias,
182
+ node_id: node?.node_id ?? null,
183
+ });
184
+ return node;
185
+ });
163
186
 
164
187
  // RFC-010 §4.2.1 C4 — broadcast node.renamed SSE. (#84 实施补漏: the Server
165
188
  // surface originally missed C4; N站马's dashboard slice needs this event.)
@@ -174,9 +197,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
174
197
  txn_id: txnId,
175
198
  alias: txn.new_alias,
176
199
  network_id: txn.network_id,
200
+ node_id: renamedNode?.node_id ?? null,
177
201
  data: {
178
202
  old_alias: txn.old_alias,
179
203
  new_alias: txn.new_alias,
204
+ node_id: renamedNode?.node_id ?? null,
180
205
  surfaces_updated: ["config", "tmux", "commhub", "dashboard", "batch_prefix", "session_resume"],
181
206
  history_policy: "preserve",
182
207
  },
package/src/tools.ts CHANGED
@@ -119,11 +119,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
119
119
 
120
120
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
121
121
  const params: any[] = [alias];
122
- let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
122
+ let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
123
123
  sql = addScope(sql, params, networkId);
124
124
  return db.get<any>(sql, ...params);
125
125
  };
126
126
 
127
+ const resolveNodeIdForAlias = (alias: string, networkId?: string | null): string | null => {
128
+ if (!alias || alias === "hub" || alias === "api") return null;
129
+ const canonical = resolveCanonicalAlias(networkId, alias);
130
+ const session = scopedSessionStatus(canonical.alias, networkId);
131
+ return session?.node_id ?? null;
132
+ };
133
+
127
134
  const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
128
135
  const session = scopedSessionStatus(alias, networkId);
129
136
  if (!session) {
@@ -746,20 +753,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
746
753
 
747
754
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
748
755
  const id = uuidv4();
756
+ const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
757
+ const targetNodeId = target.session?.node_id ?? null;
749
758
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
750
759
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
751
760
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
752
761
  // 报告冲突)。
753
762
  db.transaction(() => {
754
763
  db.run(
755
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id, meta_json)
756
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7, ?8)`,
757
- [id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
764
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
765
+ VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
766
+ [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
758
767
  );
759
768
  db.run(
760
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
761
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
762
- [id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
769
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
770
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
771
+ [id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
763
772
  );
764
773
  const touchParams: any[] = [task.slice(0, 200), targetAlias];
765
774
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
@@ -833,22 +842,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
833
842
  async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
834
843
  const effectiveNetId = getNetworkId(null);
835
844
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
836
- const target = resolveDeliveryTarget(alias, effectiveNetId);
845
+ const canonical = resolveCanonicalAlias(effectiveNetId, alias);
846
+ const targetAlias = canonical.alias;
847
+ const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
837
848
  if (target.state === "not_found") return deliveryTargetReply(target)!;
838
- console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
849
+ console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
839
850
  const id = uuidv4();
840
851
  db.run(
841
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
842
- VALUES (?1, ?2, 'message', 'normal', ?3, ?4, ?5)`,
843
- [id, alias, message, from_session, effectiveNetId ?? null]
852
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
853
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
854
+ [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
844
855
  );
845
856
 
846
857
  if (target.state === "online") {
847
- pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
858
+ pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
848
859
  }
849
860
 
850
861
  const offlineReply = deliveryTargetReply(target, { message_id: id });
851
- if (offlineReply) return offlineReply;
862
+ if (offlineReply) {
863
+ const payload = JSON.parse(offlineReply.content[0].text);
864
+ offlineReply.content[0].text = JSON.stringify({
865
+ ...payload,
866
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
867
+ });
868
+ return offlineReply;
869
+ }
852
870
 
853
871
  return {
854
872
  content: [
@@ -857,6 +875,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
857
875
  text: JSON.stringify({
858
876
  ok: true,
859
877
  message_id: id,
878
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
860
879
  session_status: target.session?.status ?? "unknown",
861
880
  }),
862
881
  },
@@ -917,11 +936,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
917
936
  };
918
937
  }
919
938
  }
939
+ const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
920
940
  const replyLogged = db.transaction(() => {
921
941
  db.run(
922
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
923
- VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
924
- [id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
942
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
943
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
944
+ [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
925
945
  );
926
946
 
927
947
  // 更新 tasks 表
@@ -1053,9 +1073,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1053
1073
  // Re-queue in inbox with new ID (original ID may already exist)
1054
1074
  const retryInboxId = uuidv4();
1055
1075
  db.run(
1056
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
1057
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
1058
- [retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
1076
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1077
+ VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1078
+ [retryInboxId, task.to_name, task.to_node_id ?? resolveNodeIdForAlias(task.to_name, effectiveNetId ?? task.network_id ?? null), task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
1059
1079
  );
1060
1080
  });
1061
1081
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
@@ -1098,18 +1118,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1098
1118
  alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
1099
1119
  status: z.string().max(50).optional().describe("Filter by status"),
1100
1120
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1121
+ from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1101
1122
  network_id: z.string().max(200).optional().describe("Filter by network"),
1102
1123
  limit: z.number().min(1).max(100).optional().default(20),
1103
1124
  },
1104
- async ({ alias, status, from_name, network_id: netId, limit }) => {
1125
+ async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
1105
1126
  const readScope = resolveReadScope(netId);
1106
1127
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1107
- let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
1128
+ let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
1108
1129
  const params: any[] = [];
1109
1130
  sql = addReadScope(sql, params, readScope);
1110
1131
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1111
1132
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1112
1133
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1134
+ if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1113
1135
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
1114
1136
  params.push(limit);
1115
1137
  const tasks = db.all(sql, ...params);
@@ -1184,6 +1206,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1184
1206
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
1185
1207
  }
1186
1208
  const oldAlias = task.to_name;
1209
+ const canonical = resolveCanonicalAlias(effectiveNetId ?? task.network_id ?? null, new_alias);
1210
+ const reassignedAlias = canonical.alias;
1211
+ const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1212
+ const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
1187
1213
  db.transaction(() => {
1188
1214
  // Ack old inbox to prevent original agent from picking it up
1189
1215
  const inboxParams: any[] = [task_id];
@@ -1191,18 +1217,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1191
1217
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1192
1218
  db.run(inboxSql, inboxParams);
1193
1219
 
1194
- const updateParams: any[] = [new_alias, task_id];
1195
- let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2";
1220
+ const updateParams: any[] = [reassignedAlias, newNodeId, task_id];
1221
+ let updateSql = "UPDATE tasks SET to_name = ?1, to_node_id = ?2, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?3";
1196
1222
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1197
1223
  db.run(updateSql, updateParams);
1198
1224
 
1199
1225
  const newInboxId = uuidv4();
1200
- 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)",
1201
- [newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1226
+ db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)",
1227
+ [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1202
1228
  });
1203
- logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
1204
- pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
1205
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
1229
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1230
+ pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
1231
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
1206
1232
  }
1207
1233
  );
1208
1234
 
@@ -1219,21 +1245,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1219
1245
  const effectiveNetId = getNetworkId(netId);
1220
1246
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1221
1247
  console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
1222
- let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
1248
+ let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
1223
1249
  const params: any[] = [];
1224
1250
  sql = addScope(sql, params, effectiveNetId);
1225
1251
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1226
1252
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
1227
1253
 
1228
- const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
1254
+ const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1229
1255
  const ids: string[] = [];
1230
1256
 
1231
1257
  for (const t of targets) {
1232
1258
  const id = uuidv4();
1233
1259
  db.run(
1234
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
1235
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub', ?4)`,
1236
- [id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
1260
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1261
+ VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1262
+ [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
1237
1263
  );
1238
1264
  ids.push(id);
1239
1265
  }