@sleep2agi/commhub-server 0.9.0-preview.27 → 0.9.0-preview.29

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.
Files changed (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
package/src/server.ts CHANGED
@@ -2,10 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
- import { db, logTaskEvent, logAudit } from "./db.js";
5
+ import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
6
6
  import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
7
7
  import { assertNodeActive } from "./lifecycle-guard.js";
8
- import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
8
+ import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
9
9
  import { validateAvatarUrl } from "./avatar-validate.js";
10
10
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
11
11
  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";
@@ -29,10 +29,24 @@ import {
29
29
  validateAttachments,
30
30
  validateIndexEntry,
31
31
  } from "./uploads.js";
32
- import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
32
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
33
33
  import { dirname as pathDirname } from "path";
34
34
  import { startRetentionSweeper } from "./retention.js";
35
35
  import { startStaleSessionSweeper } from "./stale-sweeper.js";
36
+ import {
37
+ AUDIT_LOG_REST_SELECT,
38
+ COMPLETION_REST_SELECT,
39
+ NETWORK_REST_SELECT,
40
+ SESSION_REST_SELECT,
41
+ TASK_EVENT_REST_SELECT,
42
+ TASK_REST_SELECT,
43
+ } from "./rest-projections.js";
44
+ import { resolveRestFromSession } from "./rest-identity.js";
45
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
46
+ import { diagnoseTask } from "./task-diagnostic.js";
47
+ import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
48
+ import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
49
+ import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
36
50
 
37
51
  const PORT = Number(process.env.PORT) || 9200;
38
52
  const HOST = process.env.HOST || "127.0.0.1";
@@ -124,19 +138,22 @@ function createServer(clientIP?: string, enforceNetworkId?: string | null, enfor
124
138
  }
125
139
 
126
140
  // ── Auth helper ─────────────────────────────────────
127
- function requestToken(req: Request): string {
141
+ type RequestTokenOptions = { allowQueryToken?: boolean };
142
+
143
+ function requestToken(req: Request, options: RequestTokenOptions = {}): string {
128
144
  const header = req.headers.get("Authorization")?.replace("Bearer ", "");
145
+ if (options.allowQueryToken === false) return header || "";
129
146
  const url = new URL(req.url);
130
147
  return header || url.searchParams.get("token") || "";
131
148
  }
132
149
 
133
- function isLegacyAuthToken(req: Request): boolean {
134
- const token = requestToken(req);
150
+ function isLegacyAuthToken(req: Request, options: RequestTokenOptions = {}): boolean {
151
+ const token = requestToken(req, options);
135
152
  return !!AUTH_TOKEN && token === AUTH_TOKEN;
136
153
  }
137
154
 
138
- function requireAuth(req: Request): Response | null {
139
- const token = requestToken(req);
155
+ function requireAuth(req: Request, options: RequestTokenOptions = {}): Response | null {
156
+ const token = requestToken(req, options);
140
157
 
141
158
  // V3: check api_tokens first
142
159
  if (token) {
@@ -209,8 +226,8 @@ function requireTmuxAccess(req: Request, server?: any): Response | null {
209
226
  }
210
227
 
211
228
  // Extract user + network + token-binding identity from request token.
212
- function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
213
- const token = requestToken(req);
229
+ function resolveRequestAuth(req: Request, options: RequestTokenOptions = {}): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
230
+ const token = requestToken(req, options);
214
231
  if (!token) return null;
215
232
  const resolved = resolveToken(token);
216
233
  if (!resolved) return null;
@@ -229,14 +246,14 @@ export type Principal =
229
246
  | { kind: "ntok"; userId: string; boundNetworkId: string }
230
247
  | { kind: "admin-utok"; userId: string; username: string };
231
248
 
232
- export function resolvePrincipal(req: Request): Principal {
233
- const authCtx = resolveRequestAuth(req);
249
+ export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}): Principal {
250
+ const authCtx = resolveRequestAuth(req, options);
234
251
  if (!authCtx) {
235
- if (isLegacyAuthToken(req)) return { kind: "legacy-master" };
252
+ if (isLegacyAuthToken(req, options)) return { kind: "legacy-master" };
236
253
  if (DEV_OPEN) return { kind: "dev-open-anon" };
237
254
  return { kind: "anonymous" };
238
255
  }
239
- const token = requestToken(req);
256
+ const token = requestToken(req, options);
240
257
  const resolved = token ? resolveToken(token) : null;
241
258
  const isAdmin = !!resolved && resolved.user.role === "admin";
242
259
  // Bound network wins over admin classification: a ntok_ issued BY an
@@ -366,6 +383,26 @@ function parseSqliteTime(value: string | null | undefined): number {
366
383
  return Number.isFinite(ts) ? ts : 0;
367
384
  }
368
385
 
386
+ function normalizeTaskBeforeCursor(value: string): string | null {
387
+ const cursor = value.trim();
388
+ if (!cursor) return null;
389
+ const sqliteShape = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
390
+ if (sqliteShape.test(cursor)) {
391
+ const parsed = new Date(`${cursor.replace(" ", "T")}Z`);
392
+ if (!Number.isFinite(parsed.getTime()) || sqliteTime(parsed) !== cursor) return null;
393
+ return cursor;
394
+ }
395
+ const timestamp = Date.parse(cursor);
396
+ if (!Number.isFinite(timestamp)) return null;
397
+ return sqliteTime(new Date(timestamp));
398
+ }
399
+
400
+ function normalizeTaskBeforeTaskId(value: string): string | null {
401
+ const taskId = value.trim();
402
+ if (!taskId || taskId.length > 256 || /[\u0000-\u001f\u007f]/.test(taskId)) return null;
403
+ return taskId;
404
+ }
405
+
369
406
  function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
370
407
  if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
371
408
  return Math.round((load / cores) * 1000) / 10;
@@ -491,6 +528,17 @@ function resolveRestNodeIdForAlias(alias: string, networkId: string | null): str
491
528
  return target.state === "not_found" ? null : (target.session?.node_id ?? null);
492
529
  }
493
530
 
531
+ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T & { name: string | null } {
532
+ return {
533
+ ...row,
534
+ // `network_name` is the canonical V3 storage/API field and remains for
535
+ // existing CLI/Dashboard clients. `name` is the documented REST-friendly
536
+ // compatibility alias; never let it drift or serialize as null when the
537
+ // canonical NOT NULL field is present.
538
+ name: typeof row.network_name === "string" ? row.network_name : null,
539
+ };
540
+ }
541
+
494
542
  // ── REST input schema ───────────────────────────────
495
543
  const TaskSchema = z.object({
496
544
  alias: z.string().min(1).max(200),
@@ -585,23 +633,45 @@ const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
585
633
  // Registered in startHub() (#476): this WRITES the DB, so it must never
586
634
  // run as an import side effect — a tool/test importing this module with
587
635
  // COMMHUB_DB unset would patrol the production database.
588
- function patrolExpiredTasks(): void {
636
+ export function patrolExpiredTasks(): void {
589
637
  try {
590
- const result = db.run(
591
- `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
592
- WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
593
- AND status IN ('created', 'delivered')`
594
- );
595
- if (result.changes > 0) {
596
- console.log(`[patrol] expired ${result.changes} stale task(s)`);
597
- // Log events for expired tasks
598
- const expired = db.all<{ task_id: string }>(
599
- "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')");
600
- for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
638
+ const expired = db.transaction(() => {
639
+ const due = db.all<{ task_id: string; network_id: string | null }>(
640
+ `SELECT task_id, network_id FROM tasks
641
+ WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
642
+ AND status IN ('created', 'delivered')`,
643
+ );
644
+ const changed: Array<{ task_id: string; network_id: string | null }> = [];
645
+ for (const task of due) {
646
+ const result = db.run(
647
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
648
+ WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
649
+ [task.task_id],
650
+ );
651
+ if (result.changes !== 1) continue;
652
+ syncScheduledRunForTask(task.task_id, task.network_id);
653
+ changed.push(task);
654
+ }
655
+ return changed;
656
+ });
657
+ if (expired.length > 0) {
658
+ console.log(`[patrol] expired ${expired.length} stale task(s)`);
659
+ for (const task of expired) logTaskEvent(task.task_id, null, "expired", "patrol");
601
660
  }
602
661
  } catch {}
603
662
  }
604
663
 
664
+ function patrolDeliveredStaleTasks(): void {
665
+ try {
666
+ const result = recordDeliveredStaleEvents();
667
+ if (result.inserted > 0) {
668
+ console.warn(`[patrol] recorded ${result.inserted} delivered-stale task warning(s)`);
669
+ }
670
+ } catch (error: any) {
671
+ console.error(`[patrol] delivered-stale watcher failed: ${error?.message || error}`);
672
+ }
673
+ }
674
+
605
675
  // #434 — test-safety seam, same signature as PR #438 so the two branches
606
676
  // merge cleanly. Wraps the sole Bun.serve config in a factory so
607
677
  // integration tests can request an OS-assigned ephemeral port
@@ -814,7 +884,9 @@ return Bun.serve({
814
884
 
815
885
  // ── V3: License endpoints ──
816
886
  if (url.pathname === "/api/license" && req.method === "GET") {
817
- const license = db.get<any>("SELECT * FROM licenses ORDER BY created_at LIMIT 1");
887
+ const license = db.get<any>(
888
+ "SELECT type, expires_at, max_agents, max_networks, max_tasks_day FROM licenses ORDER BY created_at LIMIT 1",
889
+ );
818
890
  if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
819
891
  const now = new Date().toISOString().replace("T", " ").slice(0, 19);
820
892
  const expired = license.expires_at && license.expires_at < now;
@@ -966,7 +1038,7 @@ return Bun.serve({
966
1038
  try {
967
1039
  const body = await req.json() as any;
968
1040
  if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
969
- const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
1041
+ const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name, body.node_id);
970
1042
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
971
1043
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
972
1044
  } catch (e: any) {
@@ -1072,11 +1144,26 @@ return Bun.serve({
1072
1144
  // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
1073
1145
  if (resolved.networkId) {
1074
1146
  // ntok_ — only return the bound network
1075
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
1076
- return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
1077
- }
1078
- const networks = getUserAllNetworks(resolved.user.user_id);
1079
- return withCors(req, Response.json({ ok: true, networks }));
1147
+ const net = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, resolved.networkId);
1148
+ return withCors(req, Response.json({ ok: true, networks: net ? [withNetworkNameAlias(net)] : [] }));
1149
+ }
1150
+ // Global Hub admins have cross-network authority throughout the REST
1151
+ // surface, so limiting this discovery endpoint to their membership rows
1152
+ // made otherwise-authorized networks impossible to select (#94). Keep
1153
+ // ordinary utok_ callers membership-scoped; ntok_ returned above stays
1154
+ // bound to exactly one network.
1155
+ const networks = resolved.user.role === "admin"
1156
+ ? db.all<any>(
1157
+ `SELECT ${NETWORK_REST_SELECT},
1158
+ COALESCE((SELECT nm.role FROM network_members nm
1159
+ WHERE nm.network_id = networks.network_id
1160
+ AND nm.user_id = ?1), 'admin') AS member_role
1161
+ FROM networks ORDER BY created_at`,
1162
+ resolved.user.user_id,
1163
+ )
1164
+ : getUserAllNetworks(resolved.user.user_id);
1165
+ const visibleNetworks = networks.map(withNetworkNameAlias);
1166
+ return withCors(req, Response.json({ ok: true, networks: visibleNetworks }));
1080
1167
  }
1081
1168
 
1082
1169
  if (url.pathname === "/api/networks" && req.method === "POST") {
@@ -1178,7 +1265,7 @@ return Bun.serve({
1178
1265
  const resolved = resolveToken(token);
1179
1266
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1180
1267
  const networkId = netDetailMatch[1];
1181
- const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
1268
+ const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
1182
1269
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
1183
1270
  // Membership check: must be a member or system admin
1184
1271
  const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
@@ -1329,6 +1416,14 @@ return Bun.serve({
1329
1416
  multi_network: true,
1330
1417
  license: license?.type || "none",
1331
1418
  uptime: Math.floor(process.uptime()),
1419
+ // #496 — public, stable capability data. Dashboard upload proxies
1420
+ // need the Hub's real limits before opening a streaming request;
1421
+ // duplicating these constants client-side creates a silent
1422
+ // lower-limit drift that the Hub can never observe.
1423
+ limits: {
1424
+ max_upload_bytes: MAX_UPLOAD_BYTES,
1425
+ max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
1426
+ },
1332
1427
  };
1333
1428
  if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
1334
1429
  return withCors(req, Response.json(body));
@@ -1347,6 +1442,33 @@ return Bun.serve({
1347
1442
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
1348
1443
  }
1349
1444
 
1445
+ // RFC-036 owner-gated host schedule edits. This handler independently
1446
+ // verifies exact node ownership and node-token binding; network admin is
1447
+ // deliberately not an override.
1448
+ const requestCredential = requestToken(req);
1449
+ const externalScheduleEditResponse = await handleExternalScheduleEditRequest({
1450
+ req,
1451
+ url,
1452
+ auth: restAuth,
1453
+ isUserToken: requestCredential.startsWith("utok_"),
1454
+ isNodeToken: requestCredential.startsWith("ntok_"),
1455
+ });
1456
+ if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
1457
+
1458
+ // Hub-owned scheduled tasks. Dashboard and mobile are management
1459
+ // clients only; each occurrence is dispatched as an ordinary task by the
1460
+ // scheduler started in startHub(). Node tokens are rejected in the
1461
+ // handler so an agent cannot grant itself a persistent execution loop.
1462
+ const scheduledResponse = await handleScheduledTaskRequest({
1463
+ req,
1464
+ url,
1465
+ auth: restAuth,
1466
+ isAdmin,
1467
+ isNodeToken: requestToken(req).startsWith("ntok_"),
1468
+ scope: restScope,
1469
+ });
1470
+ if (scheduledResponse) return withCors(req, scheduledResponse);
1471
+
1350
1472
  // ── #473 REST: SSE connection detail (ops-only) ──
1351
1473
  // The per-key breakdown /health used to expose anonymously: keys are
1352
1474
  // `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
@@ -1385,10 +1507,10 @@ return Bun.serve({
1385
1507
  const params: any[] = [];
1386
1508
  let sql = isLight
1387
1509
  ? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
1388
- : "SELECT * FROM sessions WHERE 1=1";
1510
+ : `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
1389
1511
  sql = addNetworkScope(sql, params, restScope);
1390
1512
  sql += " ORDER BY updated_at DESC";
1391
- // `model` comes straight from the sessions row (SELECT *); `runtime` is
1513
+ // `model` comes straight from the explicit sessions projection; `runtime` is
1392
1514
  // derived from the raw `agent` field. Both default to null for old nodes
1393
1515
  // that never reported a model — the dashboard falls back to a placeholder.
1394
1516
  const sessions = db.all(sql, ...params).map((s: any) => {
@@ -1404,8 +1526,14 @@ return Bun.serve({
1404
1526
  network_id: s.network_id ?? null,
1405
1527
  };
1406
1528
  }
1529
+ const externalSchedules = (() => {
1530
+ if (typeof s.external_schedules !== "string") return null;
1531
+ try { return JSON.parse(s.external_schedules); } catch { return null; }
1532
+ })();
1533
+ const { external_schedules: _externalSchedulesJson, ...publicSession } = s;
1407
1534
  return {
1408
- ...s,
1535
+ ...publicSession,
1536
+ external_schedules: externalSchedules,
1409
1537
  model: s.model ?? null,
1410
1538
  runtime: normalizeRuntime(s.agent),
1411
1539
  host: {
@@ -1522,7 +1650,7 @@ return Bun.serve({
1522
1650
  if (detailKind === "agents") {
1523
1651
  const params: any[] = [host, host];
1524
1652
  let sql = `
1525
- SELECT alias, agent, status, task, progress, model, hostname, ip,
1653
+ SELECT alias, agent, status, task, progress, model, hostname, ip, external_schedules,
1526
1654
  cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
1527
1655
  disk_avail_gb, disk_used_gb, disk_total_gb,
1528
1656
  process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
@@ -1540,6 +1668,10 @@ return Bun.serve({
1540
1668
  status: s.status ?? "offline",
1541
1669
  task: s.task ?? null,
1542
1670
  progress: s.progress ?? 0,
1671
+ external_schedules: (() => {
1672
+ if (typeof s.external_schedules !== "string") return null;
1673
+ try { return JSON.parse(s.external_schedules); } catch { return null; }
1674
+ })(),
1543
1675
  last_seen: s.last_seen ?? null,
1544
1676
  health: agentHealthChip(s.status, s.last_seen),
1545
1677
  hostname: s.hostname ?? null,
@@ -1786,7 +1918,7 @@ return Bun.serve({
1786
1918
  }
1787
1919
 
1788
1920
  if (uploadNetId !== null) {
1789
- const networkRow = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", uploadNetId);
1921
+ const networkRow = db.get<any>("SELECT network_id FROM networks WHERE network_id = ?1", uploadNetId);
1790
1922
  if (!networkRow) {
1791
1923
  if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
1792
1924
  return earlyReject(Response.json({
@@ -1853,14 +1985,14 @@ return Bun.serve({
1853
1985
  try {
1854
1986
  mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
1855
1987
  const buf = Buffer.from(await file.arrayBuffer());
1856
- writeFileSync(storage.absolutePath, buf);
1988
+ // #693 — write blob to .tmp then rename for crash-safe durability.
1989
+ const tmpPath = storage.absolutePath + ".tmp";
1990
+ writeFileSync(tmpPath, buf);
1857
1991
  // Defense-in-depth: after writing, confirm the path still sits
1858
1992
  // inside the uploads root (catches a symlink swap or root env
1859
1993
  // poisoning across processes).
1860
- if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1861
- // Refuse to acknowledge a write that landed outside the root.
1862
- // Don't try to clean up — better to keep evidence on disk than
1863
- // chase a symlink for cleanup.
1994
+ if (!isPathInsideUploadsRoot(tmpPath) || !isPathInsideUploadsRoot(storage.absolutePath)) {
1995
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
1864
1996
  console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
1865
1997
  return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
1866
1998
  }
@@ -1883,6 +2015,18 @@ return Bun.serve({
1883
2015
  ...(uploadNetId ? { network_id: uploadNetId } : {}),
1884
2016
  uploaded_at: new Date().toISOString(),
1885
2017
  }, null, 2));
2018
+ } else {
2019
+ // No index path → refuse to leave an unindexed blob.
2020
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
2021
+ return withCors(req, Response.json({ ok: false, error: "index_path_error" }, { status: 500 }));
2022
+ }
2023
+ // Index first, then publish blob via rename — if rename fails, drop index.
2024
+ try {
2025
+ renameSync(tmpPath, storage.absolutePath);
2026
+ } catch (renameErr: any) {
2027
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
2028
+ try { if (idxPath) unlinkSync(idxPath); } catch { /* best-effort */ }
2029
+ throw renameErr;
1886
2030
  }
1887
2031
  } catch (e: any) {
1888
2032
  console.error("[/api/upload] write failed:", e?.message);
@@ -1920,7 +2064,11 @@ return Bun.serve({
1920
2064
  // allow-list inline, or the branches will drift.
1921
2065
  const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
1922
2066
  if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
1923
- const authErr = requireAuth(req);
2067
+ // #501: file credentials must never travel in the URL. Query tokens
2068
+ // leak through browser history, access/proxy logs, copied links, and
2069
+ // Referer. SSE/EventSource compatibility keeps the global query-token
2070
+ // fallback for now; file GET/HEAD deliberately opt out here.
2071
+ const authErr = requireAuth(req, { allowQueryToken: false });
1924
2072
  if (authErr) return withCors(req, authErr);
1925
2073
 
1926
2074
  const fileId = decodeURIComponent(fileMatch[1]);
@@ -1951,7 +2099,7 @@ return Bun.serve({
1951
2099
  // "no such file". Both branches return the same 404 shape
1952
2100
  // for BOTH GET and HEAD (HEAD honours body-omission but the
1953
2101
  // status code and headers are the authoritative signal).
1954
- if (!authorizeFileDownload(resolvePrincipal(req), normalizeEntry(entry))) {
2102
+ if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry))) {
1955
2103
  return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1956
2104
  }
1957
2105
 
@@ -2066,10 +2214,16 @@ return Bun.serve({
2066
2214
  }
2067
2215
  taskNetId = body.network_id;
2068
2216
  } else {
2069
- taskNetId = restAuth ? singleNetworkId(restScope) : null;
2217
+ taskNetId = restAuth
2218
+ ? resolveRestWriteNetworkId(restScope, restAuth, isAdmin)
2219
+ : null;
2070
2220
  }
2071
2221
  if (restAuth && !taskNetId) {
2072
- return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
2222
+ return withCors(req, Response.json({
2223
+ ok: false,
2224
+ error: "network_id_required",
2225
+ message: "network_id is required when the user token has zero or multiple network memberships",
2226
+ }, { status: 400 }));
2073
2227
  }
2074
2228
  if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
2075
2229
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
@@ -2088,7 +2242,38 @@ return Bun.serve({
2088
2242
  }, { status: 404 }));
2089
2243
  }
2090
2244
  const id = crypto.randomUUID();
2091
- const fromSession = body.from || "api";
2245
+ // Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
2246
+ // fromIdentityMismatchReply). Both transports resolve the same
2247
+ // api_tokens row through resolveToken, and auth.ts already documents the
2248
+ // invariant: "Network-bound node tokens are an identity boundary: they
2249
+ // must not spoof another node via from_session."
2250
+ //
2251
+ // MCP enforced it; this REST handler did not — it took `body.from`
2252
+ // verbatim, so any node could POST /api/task with another node's alias
2253
+ // and be recorded (and, once sender labels ship, *rendered in a human's
2254
+ // TUI*) as that node. Same table, same token, one transport hardened and
2255
+ // its sibling not.
2256
+ //
2257
+ // User tokens keep the previous behaviour: the Dashboard legitimately
2258
+ // posts as its logged-in user, and tightening that is a separate change
2259
+ // with its own compatibility surface.
2260
+ // The decision itself lives in rest-identity.ts as a pure function so the
2261
+ // shipped path and the tested path are the same code.
2262
+ const identity = resolveRestFromSession({
2263
+ token: requestToken(req),
2264
+ tokenName: restAuth?.tokenName,
2265
+ requestedFrom: body.from,
2266
+ });
2267
+ if (!identity.ok) {
2268
+ return withCors(req, Response.json({
2269
+ ok: false,
2270
+ error: identity.error,
2271
+ message: identity.message,
2272
+ token_alias: identity.tokenAlias,
2273
+ requested_from_session: identity.requestedFromSession,
2274
+ }, { status: 403 }));
2275
+ }
2276
+ const fromSession = identity.fromSession;
2092
2277
  const ttlSeconds = (body as any).ttl_seconds || 3600;
2093
2278
  const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
2094
2279
  const targetNodeId = target.session?.node_id ?? null;
@@ -2105,7 +2290,13 @@ return Bun.serve({
2105
2290
  const mergedMeta = attachmentsResult.attachments.length
2106
2291
  ? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
2107
2292
  : (body as any).meta;
2108
- const metaJson = normalizeMetaJson(mergedMeta);
2293
+ const rawToken = requestToken(req);
2294
+ const authOrigin: TaskAuthOrigin = rawToken.startsWith("ntok_")
2295
+ ? "node"
2296
+ : restAuth
2297
+ ? "user"
2298
+ : "legacy";
2299
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
2109
2300
 
2110
2301
  // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
2111
2302
  // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
@@ -2143,8 +2334,8 @@ return Bun.serve({
2143
2334
  // dispatched via REST (anet demo, dashboard Dispatch button, etc.).
2144
2335
  db.transaction(() => {
2145
2336
  db.run(
2146
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
2147
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
2337
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
2338
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
2148
2339
  [id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
2149
2340
  );
2150
2341
  db.run(
@@ -2369,7 +2560,7 @@ return Bun.serve({
2369
2560
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
2370
2561
  const action = url.searchParams.get("action");
2371
2562
  const userId = url.searchParams.get("user_id");
2372
- let sql = "SELECT * FROM audit_log WHERE 1=1";
2563
+ let sql = `SELECT ${AUDIT_LOG_REST_SELECT} FROM audit_log WHERE 1=1`;
2373
2564
  const params: any[] = [];
2374
2565
  // Non-admin can only see own logs
2375
2566
  if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
@@ -2385,7 +2576,7 @@ return Bun.serve({
2385
2576
  if (url.pathname === "/api/task_events") {
2386
2577
  const taskId = url.searchParams.get("task_id");
2387
2578
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
2388
- let sql = "SELECT * FROM task_events WHERE 1=1";
2579
+ let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
2389
2580
  const params: any[] = [];
2390
2581
  sql = addNetworkScope(sql, params, restScope);
2391
2582
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
@@ -2400,7 +2591,7 @@ return Bun.serve({
2400
2591
  if (nodeDeleteMatch && req.method === "DELETE") {
2401
2592
  const ref = decodeURIComponent(nodeDeleteMatch[1]);
2402
2593
  const params: any[] = [ref, ref, ref];
2403
- let sql = "SELECT * FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2594
+ let sql = "SELECT node_id, node_name, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2404
2595
  sql = addNetworkScope(sql, params, restScope);
2405
2596
  sql += " ORDER BY updated_at DESC LIMIT 1";
2406
2597
  const node = db.get<any>(sql, ...params);
@@ -2823,14 +3014,45 @@ return Bun.serve({
2823
3014
  if (taskPathMatch && req.method === "GET") {
2824
3015
  const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
2825
3016
  const params: any[] = [taskId];
2826
- let sql = "SELECT * FROM tasks WHERE task_id = ?1";
3017
+ let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
2827
3018
  sql = addNetworkScope(sql, params, restScope);
2828
3019
  sql += " LIMIT 1";
2829
3020
  const task = db.get(sql, ...params);
2830
3021
  if (!task) {
2831
3022
  return withCors(req, Response.json({ ok: false, error: "task_not_found", task_id: taskId }, { status: 404 }));
2832
3023
  }
2833
- return withCors(req, Response.json({ ok: true, task }));
3024
+ const taskNetworkId = typeof task.network_id === "string" ? task.network_id : null;
3025
+ const canonicalTarget = task.to_node_id
3026
+ ? null
3027
+ : resolveCanonicalAlias(taskNetworkId, String(task.to_name ?? "")).alias;
3028
+ const targetParams: unknown[] = [];
3029
+ let targetSql = "SELECT alias, status FROM sessions WHERE ";
3030
+ if (task.to_node_id) {
3031
+ targetSql += "node_id = ?1";
3032
+ targetParams.push(task.to_node_id);
3033
+ } else {
3034
+ targetSql += "alias = ?1";
3035
+ targetParams.push(canonicalTarget);
3036
+ }
3037
+ if (taskNetworkId) {
3038
+ targetSql += " AND network_id = ?2";
3039
+ targetParams.push(taskNetworkId);
3040
+ } else {
3041
+ targetSql += " AND network_id IS NULL";
3042
+ }
3043
+ targetSql += " ORDER BY updated_at DESC LIMIT 1";
3044
+ const targetSession = db.get<{ alias: string; status: string }>(targetSql, ...targetParams);
3045
+ const targetAlias = targetSession?.alias ?? canonicalTarget ?? String(task.to_name ?? "");
3046
+ const liveSseConnections = getSSEStats().sessions[`${taskNetworkId || "global"}:${targetAlias}`] ?? 0;
3047
+ const diagnostic = diagnoseTask({
3048
+ status: String(task.status ?? "unknown"),
3049
+ runtimeSubmittedAt: typeof task.runtime_submitted_at === "string" ? task.runtime_submitted_at : null,
3050
+ consumedAt: typeof task.consumed_at === "string" ? task.consumed_at : null,
3051
+ targetSessionStatus: targetSession?.status ?? null,
3052
+ targetSessionExists: Boolean(targetSession),
3053
+ liveSseConnections,
3054
+ });
3055
+ return withCors(req, Response.json({ ok: true, task, diagnostic }));
2834
3056
  }
2835
3057
 
2836
3058
  // ── REST: tasks table (V2) ──
@@ -2839,6 +3061,16 @@ return Bun.serve({
2839
3061
  const status = url.searchParams.get("status");
2840
3062
  const toName = url.searchParams.get("to_name");
2841
3063
  const fromName = url.searchParams.get("from_name");
3064
+ const rawBefore = url.searchParams.get("before");
3065
+ const before = rawBefore === null ? null : normalizeTaskBeforeCursor(rawBefore);
3066
+ if (rawBefore !== null && before === null) {
3067
+ return withCors(req, Response.json({ ok: false, error: "invalid_before" }, { status: 400 }));
3068
+ }
3069
+ const rawBeforeTaskId = url.searchParams.get("before_task_id");
3070
+ const beforeTaskId = rawBeforeTaskId === null ? null : normalizeTaskBeforeTaskId(rawBeforeTaskId);
3071
+ if (rawBeforeTaskId !== null && (before === null || beforeTaskId === null)) {
3072
+ return withCors(req, Response.json({ ok: false, error: "invalid_before_task_id" }, { status: 400 }));
3073
+ }
2842
3074
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
2843
3075
  // #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
2844
3076
  // never consumes `stats`; it's a global per-status table scan that
@@ -2847,14 +3079,23 @@ return Bun.serve({
2847
3079
  // {ok, tasks, count, stats} shape — backwards-compatible.
2848
3080
  const skipStats = url.searchParams.get("skip_stats") === "1";
2849
3081
 
2850
- let sql = "SELECT * FROM tasks WHERE 1=1";
3082
+ let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
2851
3083
  const params: any[] = [];
2852
3084
  sql = addNetworkScope(sql, params, restScope);
2853
3085
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
2854
3086
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
2855
3087
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
2856
3088
  if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
2857
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
3089
+ if (before && beforeTaskId) {
3090
+ const createdAtParam = params.length + 1;
3091
+ const taskIdParam = params.length + 2;
3092
+ sql += ` AND (created_at < ?${createdAtParam} OR (created_at = ?${createdAtParam} AND task_id < ?${taskIdParam}))`;
3093
+ params.push(before, beforeTaskId);
3094
+ } else if (before) {
3095
+ sql += ` AND created_at < ?${params.length + 1}`;
3096
+ params.push(before);
3097
+ }
3098
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
2858
3099
  params.push(limit);
2859
3100
 
2860
3101
  const rows = db.all(sql, ...params);
@@ -2873,7 +3114,7 @@ return Bun.serve({
2873
3114
  if (url.pathname === "/api/completions") {
2874
3115
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
2875
3116
  const params: any[] = [since];
2876
- let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
3117
+ let sql = `SELECT ${COMPLETION_REST_SELECT} FROM completions WHERE completed_at >= ?1`;
2877
3118
  sql = addNetworkScope(sql, params, restScope);
2878
3119
  sql += " ORDER BY completed_at DESC LIMIT 100";
2879
3120
  const rows = db.all(sql, ...params);
@@ -3023,6 +3264,11 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
3023
3264
  `For an additional throwaway instance use bootServer({ port: 0 }).`
3024
3265
  );
3025
3266
  }
3267
+ // The current PostgreSQL adapter opens a fresh process/connection for each
3268
+ // statement, so its transaction() cannot provide the occurrence claim +
3269
+ // task creation atomicity this scheduler requires. Refuse before opening a
3270
+ // listening socket instead of silently running an unsafe scheduler.
3271
+ assertScheduledTaskBackendSupported();
3026
3272
  const server = bootServer(opts);
3027
3273
 
3028
3274
  // Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
@@ -3054,8 +3300,13 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
3054
3300
  ? Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) : 300000;
3055
3301
  const taskPatrolMs = Number(process.env.COMMHUB_TASK_PATROL_MS) > 0
3056
3302
  ? Number(process.env.COMMHUB_TASK_PATROL_MS) : 5 * 60 * 1000;
3303
+ const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
3304
+ ? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
3057
3305
  const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
3058
3306
  const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
3307
+ patrolDeliveredStaleTasks();
3308
+ const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
3309
+ const scheduledTaskTimer = startScheduledTaskScheduler();
3059
3310
 
3060
3311
  // The Bun.serve socket is what keeps the process alive; the periodic
3061
3312
  // jobs must not — otherwise a test (or a future caller) that stops the
@@ -3064,6 +3315,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
3064
3315
  (staleSweeperTimer as any)?.unref?.();
3065
3316
  (rateLimitSweepTimer as any)?.unref?.();
3066
3317
  (taskPatrolTimer as any)?.unref?.();
3318
+ (deliveredStalePatrolTimer as any)?.unref?.();
3319
+ (scheduledTaskTimer as any)?.unref?.();
3067
3320
 
3068
3321
  // ── Graceful shutdown ───────────────────────────────
3069
3322
  function shutdown() {
@@ -3072,6 +3325,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
3072
3325
  clearInterval(staleSweeperTimer);
3073
3326
  clearInterval(rateLimitSweepTimer);
3074
3327
  clearInterval(taskPatrolTimer);
3328
+ clearInterval(deliveredStalePatrolTimer);
3329
+ clearInterval(scheduledTaskTimer);
3075
3330
  db.close();
3076
3331
  process.exit(0);
3077
3332
  }