@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/src/index.ts CHANGED
@@ -7,6 +7,23 @@ 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";
11
+ import {
12
+ FILE_ID_REGEX,
13
+ MAX_REQUEST_CONTENT_LENGTH,
14
+ MAX_UPLOAD_BYTES,
15
+ buildStoragePath,
16
+ generateFileId,
17
+ getUploadsRoot,
18
+ indexEntryPath,
19
+ isPathInsideUploadsRoot,
20
+ sanitizeExt,
21
+ sharedUploadRateLimiter,
22
+ validateAttachments,
23
+ validateIndexEntry,
24
+ } from "./uploads.js";
25
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
26
+ import { dirname as pathDirname } from "path";
10
27
 
11
28
  const PORT = Number(process.env.PORT) || 9200;
12
29
  const HOST = process.env.HOST || "127.0.0.1";
@@ -348,7 +365,7 @@ type RestDeliveryTarget =
348
365
 
349
366
  function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
350
367
  const params: any[] = [alias];
351
- 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";
352
369
  if (networkId) {
353
370
  sql += " AND network_id = ?2";
354
371
  params.push(networkId);
@@ -371,6 +388,13 @@ function resolveRestDeliveryTarget(alias: string, networkId: string | null): Res
371
388
  return { state: "online", alias, session };
372
389
  }
373
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
+
374
398
  // ── REST input schema ───────────────────────────────
375
399
  const TaskSchema = z.object({
376
400
  alias: z.string().min(1).max(200),
@@ -380,6 +404,10 @@ const TaskSchema = z.object({
380
404
  network_id: z.string().max(200).optional(),
381
405
  parent_task_id: z.string().max(200).optional(),
382
406
  ttl_seconds: z.number().min(1).max(86400).optional(),
407
+ // #221 — top-level attachments mirror the MCP send_task tool. They are
408
+ // merged into `meta.attachments` for persistence so the on-disk shape
409
+ // stays unified regardless of transport (REST or MCP).
410
+ attachments: z.any().optional(),
383
411
  meta: z.any().optional(),
384
412
  });
385
413
 
@@ -443,6 +471,13 @@ Bun.serve({
443
471
  port: PORT,
444
472
  hostname: HOST,
445
473
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
474
+ // #221 — defense-in-depth cap on the raw request body. The /api/upload
475
+ // handler also pre-checks Content-Length and post-checks parsed size
476
+ // against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
477
+ // simply guarantees Bun's own buffer won't grow past 13 MiB on any
478
+ // route. Default Bun ceiling is 128 MiB which is too permissive for
479
+ // a hub now exposed to the public internet.
480
+ maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
446
481
 
447
482
  async fetch(req, server) {
448
483
  const url = new URL(req.url, `http://localhost:${PORT}`);
@@ -578,16 +613,37 @@ Bun.serve({
578
613
  }
579
614
 
580
615
  if (url.pathname === "/api/auth/login" && req.method === "POST") {
581
- const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
582
- if (!checkRateLimit(clientIP, 10)) {
616
+ const clientIP = getLoginClientIp(req);
617
+ const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
618
+ if (!ipRate.allowed) {
583
619
  logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
584
- 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
+ ));
585
624
  }
586
625
  try {
587
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
+ }
588
635
  const result = login(body.username, body.password);
589
- if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
590
- 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
+ }
591
647
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
592
648
  } catch (e: any) {
593
649
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
@@ -679,7 +735,7 @@ Bun.serve({
679
735
  }
680
736
  const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
681
737
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
682
- 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 }));
683
739
  } catch (e: any) {
684
740
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
685
741
  }
@@ -957,37 +1013,60 @@ Bun.serve({
957
1013
  staleSql = addNetworkScope(staleSql, staleParams, restScope);
958
1014
  db.run(staleSql, staleParams);
959
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";
960
1023
  const params: any[] = [];
961
- 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";
962
1027
  sql = addNetworkScope(sql, params, restScope);
963
1028
  sql += " ORDER BY updated_at DESC";
964
1029
  // `model` comes straight from the sessions row (SELECT *); `runtime` is
965
1030
  // derived from the raw `agent` field. Both default to null for old nodes
966
1031
  // that never reported a model — the dashboard falls back to a placeholder.
967
- const sessions = db.all(sql, ...params).map((s: any) => ({
968
- ...s,
969
- model: s.model ?? null,
970
- runtime: normalizeRuntime(s.agent),
971
- host: {
972
- hostname: s.hostname ?? null,
973
- ip: s.ip ?? null,
974
- cpu_load_1min: s.cpu_load_1min ?? null,
975
- cpu_cores: s.cpu_cores ?? null,
976
- mem_total_gb: s.mem_total_gb ?? null,
977
- mem_used_gb: s.mem_used_gb ?? null,
978
- mem_avail_gb: s.mem_avail_gb ?? null,
979
- disk_total_gb: s.disk_total_gb ?? null,
980
- disk_used_gb: s.disk_used_gb ?? null,
981
- disk_avail_gb: s.disk_avail_gb ?? null,
982
- },
983
- process_telemetry: {
984
- rss_bytes: s.process_rss_bytes ?? null,
985
- rss_mb: s.process_rss_mb ?? null,
986
- cpu_pct: s.process_cpu_pct ?? null,
987
- uptime_seconds: s.process_uptime_seconds ?? null,
988
- in_flight_count: s.process_in_flight_count ?? null,
989
- },
990
- }));
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
+ });
991
1070
  const summary = sessions.reduce((acc: any, session: any) => {
992
1071
  const raw = String(session.status || "").toLowerCase();
993
1072
  if (raw === "offline") acc.offline++;
@@ -1197,6 +1276,222 @@ Bun.serve({
1197
1276
  }));
1198
1277
  }
1199
1278
 
1279
+ // ── REST: file upload (#221) ──
1280
+ // POST multipart/form-data with a single `file` field. Bearer auth.
1281
+ // 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
1282
+ // verify). Returns { ok, file_id, path, url, size, mime }. The
1283
+ // file_id is server-generated (crypto.randomUUID with hyphens
1284
+ // stripped) so a client-supplied filename never enters the storage
1285
+ // path. The download URL `/api/files/<file_id>` requires the same
1286
+ // Bearer auth and forces Content-Disposition: attachment +
1287
+ // X-Content-Type-Options: nosniff so uploaded files can never be
1288
+ // executed or served as HTML.
1289
+ if (url.pathname === "/api/upload" && req.method === "POST") {
1290
+ const authErr = requireAuth(req);
1291
+ if (authErr) return withCors(req, authErr);
1292
+
1293
+ // Rate-limit key prefers the token id (so 60/h is per-token, not
1294
+ // per-IP); falls back to IP for legacy or anonymous-but-dev-open.
1295
+ const authCtx = resolveRequestAuth(req);
1296
+ const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
1297
+ const rate = sharedUploadRateLimiter.check(rateKey);
1298
+ if (!rate.allowed) {
1299
+ const headers: Record<string, string> = {
1300
+ "X-RateLimit-Limit": String(60),
1301
+ "X-RateLimit-Remaining": "0",
1302
+ "X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
1303
+ };
1304
+ if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
1305
+ return withCors(req, new Response(
1306
+ JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
1307
+ { status: 429, headers: { ...headers, "Content-Type": "application/json" } },
1308
+ ));
1309
+ }
1310
+
1311
+ // Two-stage size cap per 通信龙 dispatch c38de7a9:
1312
+ // stage 1 — reject before reading body when Content-Length
1313
+ // exceeds the cap (saves bandwidth, fails fast)
1314
+ // stage 2 — re-verify the parsed File.size after Bun has parsed
1315
+ // the multipart envelope (defends against a missing
1316
+ // or lied Content-Length header)
1317
+ const contentLength = req.headers.get("Content-Length");
1318
+ if (!contentLength) {
1319
+ return withCors(req, Response.json(
1320
+ { ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
1321
+ { status: 411 },
1322
+ ));
1323
+ }
1324
+ const declaredBytes = Number(contentLength);
1325
+ if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
1326
+ return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
1327
+ }
1328
+ if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
1329
+ return withCors(req, Response.json(
1330
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
1331
+ { status: 413 },
1332
+ ));
1333
+ }
1334
+
1335
+ const contentType = req.headers.get("Content-Type") ?? "";
1336
+ if (!/^multipart\/form-data/i.test(contentType)) {
1337
+ return withCors(req, Response.json(
1338
+ { ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
1339
+ { status: 415 },
1340
+ ));
1341
+ }
1342
+
1343
+ let form: FormData;
1344
+ try {
1345
+ form = await req.formData();
1346
+ } catch (e: any) {
1347
+ return withCors(req, Response.json(
1348
+ { ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
1349
+ { status: 400 },
1350
+ ));
1351
+ }
1352
+ const file = form.get("file");
1353
+ if (!file || typeof file === "string") {
1354
+ return withCors(req, Response.json(
1355
+ { ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
1356
+ { status: 400 },
1357
+ ));
1358
+ }
1359
+
1360
+ // Stage 2 — re-verify parsed size against the documented cap.
1361
+ if (file.size > MAX_UPLOAD_BYTES) {
1362
+ return withCors(req, Response.json(
1363
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
1364
+ { status: 413 },
1365
+ ));
1366
+ }
1367
+
1368
+ const fileId = generateFileId();
1369
+ const ext = sanitizeExt(file.name);
1370
+ let storage;
1371
+ try {
1372
+ storage = buildStoragePath(fileId, ext);
1373
+ } catch (e: any) {
1374
+ // Should be unreachable — we generated the id ourselves — but
1375
+ // surface as 500 if our own primitives reject our own input.
1376
+ console.error("[/api/upload] buildStoragePath rejected:", e?.message);
1377
+ return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
1378
+ }
1379
+
1380
+ try {
1381
+ mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
1382
+ const buf = Buffer.from(await file.arrayBuffer());
1383
+ writeFileSync(storage.absolutePath, buf);
1384
+ // Defense-in-depth: after writing, confirm the path still sits
1385
+ // inside the uploads root (catches a symlink swap or root env
1386
+ // poisoning across processes).
1387
+ if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1388
+ // Refuse to acknowledge a write that landed outside the root.
1389
+ // Don't try to clean up — better to keep evidence on disk than
1390
+ // chase a symlink for cleanup.
1391
+ console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
1392
+ return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
1393
+ }
1394
+
1395
+ // Persist the index entry so /api/files/<file_id> can do an
1396
+ // O(1) lookup without scanning every date bucket.
1397
+ const idxPath = indexEntryPath(fileId);
1398
+ if (idxPath) {
1399
+ mkdirSync(pathDirname(idxPath), { recursive: true });
1400
+ writeFileSync(idxPath, JSON.stringify({
1401
+ file_id: fileId,
1402
+ date_bucket: storage.dateBucket,
1403
+ ext: storage.ext,
1404
+ name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
1405
+ mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
1406
+ size: file.size,
1407
+ owner: authCtx?.username ?? null,
1408
+ owner_id: authCtx?.userId ?? null,
1409
+ uploaded_at: new Date().toISOString(),
1410
+ }, null, 2));
1411
+ }
1412
+ } catch (e: any) {
1413
+ console.error("[/api/upload] write failed:", e?.message);
1414
+ return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
1415
+ }
1416
+
1417
+ return withCors(req, Response.json({
1418
+ ok: true,
1419
+ file_id: fileId,
1420
+ path: storage.absolutePath,
1421
+ url: `/api/files/${fileId}`,
1422
+ size: file.size,
1423
+ mime: typeof file.type === "string" ? file.type : "application/octet-stream",
1424
+ }));
1425
+ }
1426
+
1427
+ // ── REST: file download (#221) ──
1428
+ // GET /api/files/:file_id with Bearer auth. Always forces
1429
+ // Content-Disposition: attachment + X-Content-Type-Options: nosniff
1430
+ // so the served file can never be executed or rendered as HTML.
1431
+ // The file_id is validated against the same strict regex used at
1432
+ // generation time before any filesystem access.
1433
+ const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
1434
+ if (fileMatch && req.method === "GET") {
1435
+ const authErr = requireAuth(req);
1436
+ if (authErr) return withCors(req, authErr);
1437
+
1438
+ const fileId = decodeURIComponent(fileMatch[1]);
1439
+ if (!FILE_ID_REGEX.test(fileId)) {
1440
+ return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
1441
+ }
1442
+
1443
+ const idxPath = indexEntryPath(fileId);
1444
+ if (!idxPath || !existsSync(idxPath)) {
1445
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1446
+ }
1447
+
1448
+ let entry: any;
1449
+ try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
1450
+ return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
1451
+ }
1452
+ if (!validateIndexEntry(entry)) {
1453
+ return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1454
+ }
1455
+
1456
+ let storage;
1457
+ try {
1458
+ storage = buildStoragePath(entry.file_id, entry.ext);
1459
+ } catch {
1460
+ return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1461
+ }
1462
+ if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1463
+ // Defence: should be unreachable since buildStoragePath rejected
1464
+ // malformed inputs, but a poisoned index could try to fool us.
1465
+ return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
1466
+ }
1467
+ if (!existsSync(storage.absolutePath)) {
1468
+ return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
1469
+ }
1470
+
1471
+ // Defence: verify the on-disk size matches what the index claims
1472
+ // before serving — a tampered index could otherwise mislead the
1473
+ // caller about file integrity.
1474
+ const st = statSync(storage.absolutePath);
1475
+ if (st.size !== entry.size) {
1476
+ console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
1477
+ return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
1478
+ }
1479
+
1480
+ const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
1481
+ ? entry.name
1482
+ : `${fileId}${entry.ext}`;
1483
+ const blob = Bun.file(storage.absolutePath);
1484
+ const responseHeaders: Record<string, string> = {
1485
+ ...corsHeaders(req),
1486
+ "Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
1487
+ "Content-Length": String(entry.size),
1488
+ "Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
1489
+ "X-Content-Type-Options": "nosniff",
1490
+ "Cache-Control": "private, no-store",
1491
+ };
1492
+ return new Response(blob, { status: 200, headers: responseHeaders });
1493
+ }
1494
+
1200
1495
  // ── REST: send task ──
1201
1496
  if (url.pathname === "/api/task" && req.method === "POST") {
1202
1497
  let raw: unknown;
@@ -1243,7 +1538,22 @@ Bun.serve({
1243
1538
  const id = crypto.randomUUID();
1244
1539
  const fromSession = body.from || "api";
1245
1540
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1246
- const metaJson = normalizeMetaJson((body as any).meta);
1541
+ const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
1542
+ const targetNodeId = target.session?.node_id ?? null;
1543
+ // #221 — fold top-level `attachments` into `meta.attachments` so
1544
+ // the REST and MCP send_task transports produce identical
1545
+ // tasks.meta_json shape downstream. Top-level wins over any
1546
+ // duplicate `meta.attachments` the client supplied. Validation
1547
+ // matches the MCP-side schema (max 20 entries, file_id regex,
1548
+ // size cap, etc.).
1549
+ const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
1550
+ if (!attachmentsResult.ok) {
1551
+ return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
1552
+ }
1553
+ const mergedMeta = attachmentsResult.attachments.length
1554
+ ? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
1555
+ : (body as any).meta;
1556
+ const metaJson = normalizeMetaJson(mergedMeta);
1247
1557
 
1248
1558
  // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
1249
1559
  // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
@@ -1269,14 +1579,14 @@ Bun.serve({
1269
1579
  // dispatched via REST (anet demo, dashboard Dispatch button, etc.).
1270
1580
  db.transaction(() => {
1271
1581
  db.run(
1272
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
1273
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6, ?7)`,
1274
- [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]
1275
1585
  );
1276
1586
  db.run(
1277
- `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)
1278
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
1279
- [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]
1280
1590
  );
1281
1591
  // Touch session row so the dashboard reflects "task in flight"
1282
1592
  // immediately, without waiting for the agent's report_status to
@@ -1334,19 +1644,19 @@ Bun.serve({
1334
1644
  if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
1335
1645
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1336
1646
  }
1337
- 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";
1338
1648
  const params: any[] = [];
1339
1649
  sql = addNetworkScope(sql, params, restScope);
1340
1650
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
1341
1651
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
1342
- 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);
1343
1653
  const ids: string[] = [];
1344
1654
  for (const t of targets) {
1345
1655
  const id = crypto.randomUUID();
1346
1656
  db.run(
1347
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
1348
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
1349
- [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]
1350
1660
  );
1351
1661
  ids.push(id);
1352
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;