@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 +3 -2
- package/src/auth_login_guard.test.ts +130 -0
- package/src/auth_login_guard.ts +142 -0
- package/src/db.ts +189 -4
- package/src/event_bus.ts +10 -0
- package/src/index.ts +354 -44
- package/src/push.test.ts +47 -0
- package/src/push.ts +28 -0
- package/src/rename.ts +39 -14
- package/src/tools.ts +60 -34
- package/src/uploads-http.test.ts +208 -0
- package/src/uploads.test.ts +294 -0
- package/src/uploads.ts +240 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.
|
|
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', ?
|
|
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
|
}
|
package/src/event_bus.ts
ADDED