@wrongstack/cli 0.306.4 → 0.307.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.
Files changed (73) hide show
  1. package/dist/{auth-L3WGSGDX.js → auth-TVOOHL4Q.js} +45 -106
  2. package/dist/boot/short-circuit-flags.d.ts +2 -2
  3. package/dist/chimera-cascade-evidence.d.ts +1 -1
  4. package/dist/chimera-reviewer-policy.d.ts +1 -1
  5. package/dist/chunk-2VMJ3E5Z.js +71 -0
  6. package/dist/{chunk-RHLQT3EV.js → chunk-CB35UVXO.js} +1636 -1528
  7. package/dist/{chunk-YKBRFSGL.js → chunk-DOJLAULS.js} +28 -28
  8. package/dist/{chunk-KXN5HZDL.js → chunk-PKHGLWUJ.js} +24 -6
  9. package/dist/{chunk-HITHEXE3.js → chunk-TOG7OMKJ.js} +2 -2
  10. package/dist/{chunk-ARWGQUB3.js → chunk-XSG33WHN.js} +15 -5
  11. package/dist/{cli-main-4TZCCZR6.js → cli-main-DD3I7KOO.js} +29198 -28411
  12. package/dist/cli-main.d.ts +0 -20
  13. package/dist/{execution-547PM3NI.js → execution-4ASWJ7EY.js} +309 -273
  14. package/dist/fleet/host-learning-scheduler.d.ts +27 -0
  15. package/dist/fleet/host-shadow-manager.d.ts +64 -0
  16. package/dist/fleet/host.d.ts +5 -140
  17. package/dist/{hq-ZK6PGN3W.js → hq-PA6RH2UW.js} +2 -2
  18. package/dist/hq-server/mailbox-gateway-manager.d.ts +32 -0
  19. package/dist/hq-server/routes/auth/bootstrap-routes.d.ts +5 -0
  20. package/dist/hq-server/routes/auth/common.d.ts +11 -0
  21. package/dist/hq-server/routes/auth/password-routes.d.ts +9 -0
  22. package/dist/hq-server/routes/auth/session-audit-routes.d.ts +6 -0
  23. package/dist/hq-server/routes/auth/totp-routes.d.ts +9 -0
  24. package/dist/hq-server/routes/auth-handlers.d.ts +5 -132
  25. package/dist/hq-server/routes/command-handlers.d.ts +7 -0
  26. package/dist/hq-server/routes/data-handlers.d.ts +8 -0
  27. package/dist/hq-server/routes/mailbox-handlers.d.ts +22 -0
  28. package/dist/hq-server/routes.d.ts +3 -3
  29. package/dist/hq-server/server-lifecycle.d.ts +37 -0
  30. package/dist/hq-server/snapshot.d.ts +6 -5
  31. package/dist/hq-server/types.d.ts +4 -0
  32. package/dist/hq-server/upgrade-handler.d.ts +34 -0
  33. package/dist/hq-server/ws-client-events.d.ts +25 -0
  34. package/dist/hq-server/ws-leader.d.ts +47 -0
  35. package/dist/hq-server/ws-resume.d.ts +27 -0
  36. package/dist/hq-server/ws.d.ts +4 -71
  37. package/dist/{hq-server-IVZLVCFV.js → hq-server-PBRU275L.js} +2 -2
  38. package/dist/hq-server.d.ts +0 -38
  39. package/dist/index.js +1267 -122
  40. package/dist/{mcp-W7HHN7IA.js → mcp-CMLFHRG2.js} +14 -7
  41. package/dist/mcp-serve.d.ts +12 -1
  42. package/dist/{modeldiag-6VS5KDP5.js → modeldiag-ICMPHT5L.js} +868 -831
  43. package/dist/{providers-models-HQN3AT3K.js → providers-models-4FAKM2WO.js} +4 -4
  44. package/dist/repl-eternal-loop.d.ts +8 -0
  45. package/dist/repl-goal-banner.d.ts +21 -0
  46. package/dist/slash-commands/brain-council.d.ts +36 -0
  47. package/dist/slash-commands/brain-status.d.ts +12 -0
  48. package/dist/slash-commands/kanban-board-handlers.d.ts +13 -0
  49. package/dist/slash-commands/kanban-lifecycle-diagnostics.d.ts +16 -0
  50. package/dist/slash-commands/kanban-task-subcommands.d.ts +8 -0
  51. package/dist/slash-commands/memory-audience.d.ts +14 -0
  52. package/dist/slash-commands/memory-gather.d.ts +9 -0
  53. package/dist/slash-commands/memory-stats.d.ts +5 -0
  54. package/dist/slash-commands/settings-mutations.d.ts +6 -0
  55. package/dist/slash-commands/settings-view.d.ts +5 -0
  56. package/dist/subcommands/handlers/modeldiag-bench.d.ts +10 -0
  57. package/dist/subcommands/handlers/modeldiag-eval.d.ts +27 -0
  58. package/dist/subcommands/handlers/modeldiag-test.d.ts +8 -0
  59. package/dist/{version-help-ASKQYXDJ.js → version-help-TFD6VPF3.js} +2 -2
  60. package/dist/vibe-protocol-wiring.d.ts +2 -0
  61. package/dist/webui-server/credential-watcher.d.ts +15 -0
  62. package/dist/webui-server/domain-handlers.d.ts +21 -0
  63. package/dist/webui-server/route-contexts.d.ts +41 -0
  64. package/dist/{webui-server-FTG7JAZU.js → webui-server-IPHGJGXE.js} +515 -437
  65. package/dist/wiring/cli-execute-builder.d.ts +88 -0
  66. package/dist/wiring/cli-heap-watchdog.d.ts +33 -0
  67. package/dist/wiring/cli-prompt-and-tools-setup.d.ts +25 -0
  68. package/dist/wiring/cli-slash-commands-setup.d.ts +79 -0
  69. package/dist/wiring/heap-watchdog-setup.d.ts +53 -0
  70. package/dist/wiring/metrics.d.ts +1 -0
  71. package/dist/wiring/prompt-journal-recorder.d.ts +45 -0
  72. package/dist/wiring/replay-governance-setup.d.ts +31 -0
  73. package/package.json +27 -27
@@ -7,17 +7,12 @@ import {
7
7
  } from "./chunk-Q5GTM25S.js";
8
8
 
9
9
  // src/hq-server.ts
10
- import { randomUUID as randomUUID4 } from "node:crypto";
10
+ import { randomUUID as randomUUID6 } from "node:crypto";
11
11
  import * as fs5 from "node:fs/promises";
12
12
  import * as http from "node:http";
13
13
  import * as path7 from "node:path";
14
14
  import {
15
- createMailboxHttpRouter,
16
- createProjectMailbox,
17
- getSharedProjectMailbox,
18
- MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
19
- MailboxEventEmitter,
20
- MailboxHttpRateLimiter
15
+ getSharedProjectMailbox
21
16
  } from "@wrongstack/core/coordination";
22
17
  import {
23
18
  assessHqExposure as assessHqExposure2,
@@ -26,11 +21,9 @@ import {
26
21
  HqBootstrapCodeStore,
27
22
  HqCommandAuditLog,
28
23
  hqTokenKey as hqTokenKey2,
29
- hqTokenVerifier as hqTokenVerifier2,
30
- isLoopbackHost as isLoopbackHost3,
31
24
  isTokenExpired as isTokenExpired4,
32
25
  mintHqCookieSecret as mintHqCookieSecret2,
33
- mutateHqAuthFile as mutateHqAuthFile2,
26
+ mutateHqAuthFile as mutateHqAuthFile3,
34
27
  toAlertMessage,
35
28
  watchHqAuthFile
36
29
  } from "@wrongstack/core/hq";
@@ -583,76 +576,14 @@ var LoginAttemptStore = class _LoginAttemptStore {
583
576
  }
584
577
  };
585
578
 
586
- // src/hq-server/preflight.ts
587
- import {
588
- assessHqExposure,
589
- ensureHqFirstRunAuthFile,
590
- HqInsecureExposureError,
591
- isTokenExpired as isTokenExpired3,
592
- resolveHqDataDir
593
- } from "@wrongstack/core/hq";
594
- async function prepareHqServerStart(options, defaults) {
595
- const host = options.host ?? defaults.host;
596
- const port = options.port ?? defaults.port;
597
- const dataDir = resolveHqDataDir(options.dataDir);
598
- const firstRunAuth = await ensureHqFirstRunAuthFile(dataDir, {
599
- warn: (message) => console.warn(
600
- JSON.stringify({
601
- level: "warn",
602
- event: "hq.auth_load_failed",
603
- message,
604
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
605
- })
606
- ),
607
- actor: resolveAuditActor(),
608
- ...options.password !== void 0 ? { password: options.password } : {},
609
- ...options.tokenTtlMs !== void 0 ? { tokenTtlMs: options.tokenTtlMs } : {}
610
- });
611
- const { authFile } = firstRunAuth;
612
- const rawBrowserTokens = authFile.browserTokens ?? [];
613
- const liveBrowserTokens = rawBrowserTokens.filter((token) => !isTokenExpired3(token));
614
- const hasPassword = authFile.passwordHash !== void 0;
615
- const allTokensExpired = rawBrowserTokens.length > 0 && liveBrowserTokens.length === 0;
616
- if (options.requireBrowserAuth && liveBrowserTokens.length === 0 && !hasPassword) {
617
- throw new HqInsecureExposureError(
618
- allTokensExpired ? `HQ public relay requires browser authentication, but all ${rawBrowserTokens.length} browser token(s) in the auth file have expired. Create a fresh token or set --password.` : "HQ public relay requires browser authentication. Set --password or create a browser token first."
619
- );
620
- }
621
- const exposure = assessHqExposure({
622
- host,
623
- hasBrowserTokens: liveBrowserTokens.length > 0,
624
- hasPassword,
625
- allowInsecure: options.allowInsecureOpen
626
- });
627
- if (exposure.kind === "refuse") {
628
- throw new HqInsecureExposureError(
629
- allTokensExpired ? `${exposure.message} (all ${rawBrowserTokens.length} browser token(s) in the auth file have expired)` : exposure.message
630
- );
631
- }
632
- if (exposure.kind === "warn") {
633
- console.warn(
634
- JSON.stringify({
635
- level: "warn",
636
- event: "hq.insecure_exposure",
637
- message: exposure.message,
638
- host,
639
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
640
- })
641
- );
642
- }
643
- return { host, port, dataDir, firstRunAuth };
644
- }
645
-
646
- // src/hq-server/routes.ts
647
- import { randomUUID as randomUUID2 } from "node:crypto";
648
- import * as path5 from "node:path";
579
+ // src/hq-server/mailbox-gateway-manager.ts
649
580
  import {
650
- resolveProjectDir
581
+ createMailboxHttpRouter,
582
+ createProjectMailbox,
583
+ MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
584
+ MailboxEventEmitter,
585
+ MailboxHttpRateLimiter
651
586
  } from "@wrongstack/core/coordination";
652
- import {
653
- tokenHasCapability,
654
- validateHqCommand
655
- } from "@wrongstack/core/hq";
656
587
 
657
588
  // src/hq-static-serve.ts
658
589
  import * as fs2 from "node:fs";
@@ -758,58 +689,59 @@ async function serveHqStatic(_req, res, urlPath, distDir) {
758
689
  return { handled: true };
759
690
  }
760
691
 
761
- // src/hq-server/project-root.ts
762
- import { deriveHqProjectId } from "@wrongstack/core/hq";
763
- function resolveHqProjectRoot(globalRoot, ids) {
764
- const fn = async () => {
765
- const { getSessionRegistry } = await import("@wrongstack/core/storage");
766
- try {
767
- const registry = getSessionRegistry(globalRoot);
768
- if (typeof ids.sessionId === "string") {
769
- const entry = await registry.get(ids.sessionId).catch(() => null);
770
- if (entry?.projectRoot) return entry.projectRoot;
771
- }
772
- if (typeof ids.projectId === "string") {
773
- const { createHash: createHash2 } = await import("node:crypto");
774
- const all = await registry.list().catch(() => []);
775
- const projectIds = /* @__PURE__ */ new Map();
776
- const projectIdForRoot = (projectRoot) => {
777
- const cached = projectIds.get(projectRoot);
778
- if (cached !== void 0) return cached;
779
- const derived = deriveHqProjectId(projectRoot);
780
- projectIds.set(projectRoot, derived);
781
- return derived;
782
- };
783
- const match = all.find(
784
- (e) => e.projectSlug === ids.projectId || projectIdForRoot(e.projectRoot) === ids.projectId || createHash2("sha256").update(e.projectRoot).digest("hex").slice(0, 12) === ids.projectId
785
- );
786
- if (match) return match.projectRoot;
692
+ // src/hq-server/routes/auth/common.ts
693
+ import { isLoopbackHost as isLoopbackHost2 } from "@wrongstack/core/hq";
694
+ function isLoopbackRequest(req) {
695
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/, "");
696
+ return address !== void 0 && isLoopbackHost2(address);
697
+ }
698
+ var HQ_AUTH_ADMIN_CAPABILITY = "auth.admin";
699
+ function callerCanAdministerAuth(auth) {
700
+ if (auth === void 0) return false;
701
+ if (auth.kind === "cookie" && auth.tokenId === void 0) return true;
702
+ if (auth.capabilities === void 0) return true;
703
+ return auth.capabilities.includes(HQ_AUTH_ADMIN_CAPABILITY);
704
+ }
705
+ function writeAuthAdminRequired(res) {
706
+ res.writeHead(403, { "Content-Type": "application/json" });
707
+ res.end(
708
+ JSON.stringify({
709
+ error: {
710
+ code: "AUTH_ADMIN_REQUIRED",
711
+ message: `This endpoint requires the '${HQ_AUTH_ADMIN_CAPABILITY}' capability.`
787
712
  }
788
- } catch {
789
- }
713
+ })
714
+ );
715
+ }
716
+ function authorizeAuthAdmin(req, res, mutableAuth, sessions) {
717
+ const auth = authenticateBrowserRequest(
718
+ req,
719
+ new URL(req.url ?? "/", "http://localhost"),
720
+ mutableAuth,
721
+ sessions
722
+ );
723
+ if (!auth) {
724
+ res.writeHead(401, { "Content-Type": "application/json" });
725
+ res.end(
726
+ JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } })
727
+ );
790
728
  return void 0;
791
- };
792
- return fn();
729
+ }
730
+ if (!callerCanAdministerAuth(auth)) {
731
+ writeAuthAdminRequired(res);
732
+ return void 0;
733
+ }
734
+ return auth;
793
735
  }
794
736
 
795
- // src/hq-server/routes/auth-handlers.ts
737
+ // src/hq-server/routes/auth/password-routes.ts
796
738
  import { randomUUID } from "node:crypto";
797
739
  import {
798
740
  hashHqPassword,
799
- isLoopbackHost as isLoopbackHost2,
800
741
  mintHqCookieSecret,
801
742
  mutateHqAuthFile,
802
743
  verifyHqPassword
803
744
  } from "@wrongstack/core/hq";
804
- import {
805
- buildOtpAuthUri,
806
- generateRecoveryCodes,
807
- generateTotpSecret,
808
- hashRecoveryCode,
809
- verifyRecoveryCode,
810
- verifyTotp,
811
- verifyTotpCounter
812
- } from "@wrongstack/core/security";
813
745
 
814
746
  // src/hq-server/client-address.ts
815
747
  import { isIP as isIP2 } from "node:net";
@@ -856,7 +788,7 @@ function resolveClientAddress(req, trustedProxyHops = 0) {
856
788
  import * as fs3 from "node:fs/promises";
857
789
  import * as os from "node:os";
858
790
  import * as path3 from "node:path";
859
- import { resolveHqDataDir as resolveHqDataDir2 } from "@wrongstack/core/hq";
791
+ import { resolveHqDataDir } from "@wrongstack/core/hq";
860
792
  import { sanitizeApiError, scrubErrorDetail } from "@wrongstack/core/security";
861
793
  var TRANSCRIPT_RING_MAX = 4e3;
862
794
  var MAX_TRANSCRIPT_SESSIONS = 400;
@@ -886,7 +818,7 @@ async function readLocalSubagentTranscript(sessionId, subagentId) {
886
818
  try {
887
819
  const { getSessionRegistry } = await import("@wrongstack/core/storage");
888
820
  const { resolveWstackPaths, sessionScopedPath } = await import("@wrongstack/core/utils");
889
- const globalRoot = path3.dirname(resolveHqDataDir2());
821
+ const globalRoot = path3.dirname(resolveHqDataDir());
890
822
  const registry = getSessionRegistry(globalRoot);
891
823
  const entry = await registry.get(sessionId).catch(() => null);
892
824
  if (!entry) return null;
@@ -1044,50 +976,7 @@ function recordTimeseriesSignal(persistence, event) {
1044
976
  }
1045
977
  }
1046
978
 
1047
- // src/hq-server/routes/auth-handlers.ts
1048
- import { readHqAuthAuditTail } from "@wrongstack/core/hq";
1049
- function isLoopbackRequest(req) {
1050
- const address = req.socket.remoteAddress?.replace(/^::ffff:/, "");
1051
- return address !== void 0 && isLoopbackHost2(address);
1052
- }
1053
- var HQ_AUTH_ADMIN_CAPABILITY = "auth.admin";
1054
- function callerCanAdministerAuth(auth) {
1055
- if (auth === void 0) return false;
1056
- if (auth.kind === "cookie" && auth.tokenId === void 0) return true;
1057
- if (auth.capabilities === void 0) return true;
1058
- return auth.capabilities.includes(HQ_AUTH_ADMIN_CAPABILITY);
1059
- }
1060
- function writeAuthAdminRequired(res) {
1061
- res.writeHead(403, { "Content-Type": "application/json" });
1062
- res.end(
1063
- JSON.stringify({
1064
- error: {
1065
- code: "AUTH_ADMIN_REQUIRED",
1066
- message: `This endpoint requires the '${HQ_AUTH_ADMIN_CAPABILITY}' capability.`
1067
- }
1068
- })
1069
- );
1070
- }
1071
- function authorizeAuthAdmin(req, res, mutableAuth, sessions) {
1072
- const auth = authenticateBrowserRequest(
1073
- req,
1074
- new URL(req.url ?? "/", "http://localhost"),
1075
- mutableAuth,
1076
- sessions
1077
- );
1078
- if (!auth) {
1079
- res.writeHead(401, { "Content-Type": "application/json" });
1080
- res.end(
1081
- JSON.stringify({ error: { code: "UNAUTHORIZED", message: "Authentication required." } })
1082
- );
1083
- return void 0;
1084
- }
1085
- if (!callerCanAdministerAuth(auth)) {
1086
- writeAuthAdminRequired(res);
1087
- return void 0;
1088
- }
1089
- return auth;
1090
- }
979
+ // src/hq-server/routes/auth/password-routes.ts
1091
980
  async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, requireBrowserAuth, trustedPublicOrigins, secureCookies) {
1092
981
  const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
1093
982
  const openMode = mutableAuth.browserTokens.size === 0 && mutableAuth.passwordHash === void 0 && mutableAuth.requireAuthFloor !== true;
@@ -1338,164 +1227,51 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1338
1227
  })
1339
1228
  );
1340
1229
  }
1341
- async function handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore) {
1230
+
1231
+ // src/hq-server/routes/auth/totp-routes.ts
1232
+ import { randomUUID as randomUUID2 } from "node:crypto";
1233
+ import { mutateHqAuthFile as mutateHqAuthFile2, verifyHqPassword as verifyHqPassword2 } from "@wrongstack/core/hq";
1234
+ import {
1235
+ buildOtpAuthUri,
1236
+ generateRecoveryCodes,
1237
+ generateTotpSecret,
1238
+ hashRecoveryCode,
1239
+ verifyRecoveryCode,
1240
+ verifyTotp,
1241
+ verifyTotpCounter
1242
+ } from "@wrongstack/core/security";
1243
+ var PENDING_2FA_TTL_MS = 5 * 6e4;
1244
+ var recoveryCodeLock = Promise.resolve();
1245
+ var MAX_2FA_VERIFY_FAILURES = 5;
1246
+ function recordVerifyFailure(loginAttempts, clientIp) {
1247
+ const prev = loginAttempts.get(clientIp);
1248
+ const count = (prev?.count ?? 0) + 1;
1249
+ loginAttempts.recordFailure(clientIp);
1250
+ return count;
1251
+ }
1252
+ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies, applyAuthFile, trustedProxyHops) {
1342
1253
  if (!mutableAuth.cookieSecret) {
1343
1254
  res.writeHead(503, { "Content-Type": "application/json" });
1255
+ res.end(JSON.stringify({ error: { code: "NO_COOKIE_SECRET" } }));
1256
+ return;
1257
+ }
1258
+ const clientIp = resolveClientAddress(req, trustedProxyHops);
1259
+ const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp);
1260
+ if (blocked) {
1261
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
1344
1262
  res.end(
1345
1263
  JSON.stringify({
1346
- error: {
1347
- code: "BOOTSTRAP_UNAVAILABLE",
1348
- message: "Cookie signing is not configured on this HQ server."
1349
- }
1264
+ error: { code: "RATE_LIMITED", message: "Too many attempts. Try again later." }
1350
1265
  })
1351
1266
  );
1352
1267
  return;
1353
1268
  }
1354
- let body;
1355
- try {
1356
- body = JSON.parse(await readRequestBody(req));
1357
- } catch (error) {
1358
- writeInvalidBody(res, error);
1359
- return;
1360
- }
1361
- if (typeof body.code !== "string" || body.code.length === 0) {
1362
- res.writeHead(400, { "Content-Type": "application/json" });
1363
- res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "code is required" } }));
1364
- return;
1365
- }
1366
- const entry = bootstrapStore.consume(body.code);
1367
- if (!entry) {
1269
+ const raw = readHqSessionCookie(req.headers.cookie);
1270
+ const sessionId = raw ? parseHqSessionCookie(raw, mutableAuth.cookieSecret) : void 0;
1271
+ if (!sessionId) {
1368
1272
  res.writeHead(401, { "Content-Type": "application/json" });
1369
1273
  res.end(
1370
- JSON.stringify({
1371
- error: {
1372
- code: "INVALID_OR_EXPIRED_CODE",
1373
- message: "The bootstrap code is invalid, already used, or expired."
1374
- }
1375
- })
1376
- );
1377
- return;
1378
- }
1379
- const tokenLive = [...mutableAuth.browserTokenObjs.values()].some(
1380
- (obj) => obj.id === entry.tokenId
1381
- );
1382
- if (!tokenLive) {
1383
- res.writeHead(401, { "Content-Type": "application/json" });
1384
- res.end(
1385
- JSON.stringify({
1386
- error: {
1387
- code: "TOKEN_REVOKED",
1388
- message: "The token that issued this bootstrap code has been revoked."
1389
- }
1390
- })
1391
- );
1392
- return;
1393
- }
1394
- const sessionId = randomUUID();
1395
- sessions.set(sessionId, {
1396
- createdAt: Date.now(),
1397
- lastSeenAt: Date.now(),
1398
- kind: "token",
1399
- tokenId: entry.tokenId,
1400
- ...entry.capabilities !== void 0 ? { capabilities: entry.capabilities } : {}
1401
- });
1402
- setHqSessionCookie(
1403
- res,
1404
- serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
1405
- secureCookies
1406
- );
1407
- res.writeHead(200, { "Content-Type": "application/json" });
1408
- res.end(JSON.stringify({ loggedIn: true }));
1409
- }
1410
- async function handleApiTokenUpgrade(req, res, url, mutableAuth, sessions, secureCookies) {
1411
- if (!mutableAuth.cookieSecret) {
1412
- res.writeHead(503, { "Content-Type": "application/json" });
1413
- res.end(
1414
- JSON.stringify({
1415
- error: {
1416
- code: "UPGRADE_UNAVAILABLE",
1417
- message: "Cookie signing is not configured on this HQ server."
1418
- }
1419
- })
1420
- );
1421
- return;
1422
- }
1423
- const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
1424
- if (isCookieAuth(auth)) {
1425
- res.writeHead(200, { "Content-Type": "application/json" });
1426
- res.end(JSON.stringify({ loggedIn: true, upgraded: false }));
1427
- return;
1428
- }
1429
- if (!isTokenAuth(auth)) {
1430
- res.writeHead(401, { "Content-Type": "application/json" });
1431
- res.end(
1432
- JSON.stringify({
1433
- error: { code: "UNAUTHORIZED", message: "A valid browser token is required." }
1434
- })
1435
- );
1436
- return;
1437
- }
1438
- const tokenLive = [...mutableAuth.browserTokenObjs.values()].some((obj) => obj.id === auth.id);
1439
- if (!tokenLive) {
1440
- res.writeHead(409, { "Content-Type": "application/json" });
1441
- res.end(
1442
- JSON.stringify({
1443
- error: {
1444
- code: "TOKEN_NOT_UPGRADEABLE",
1445
- message: "This token has no server-side record to bind a session to."
1446
- }
1447
- })
1448
- );
1449
- return;
1450
- }
1451
- const sessionId = randomUUID();
1452
- sessions.set(sessionId, {
1453
- createdAt: Date.now(),
1454
- lastSeenAt: Date.now(),
1455
- kind: "token",
1456
- tokenId: auth.id,
1457
- ...auth.capabilities !== void 0 ? { capabilities: auth.capabilities } : {}
1458
- });
1459
- setHqSessionCookie(
1460
- res,
1461
- serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
1462
- secureCookies
1463
- );
1464
- res.writeHead(200, { "Content-Type": "application/json" });
1465
- res.end(JSON.stringify({ loggedIn: true, upgraded: true }));
1466
- }
1467
- var PENDING_2FA_TTL_MS = 5 * 6e4;
1468
- var recoveryCodeLock = Promise.resolve();
1469
- var MAX_2FA_VERIFY_FAILURES = 5;
1470
- function recordVerifyFailure(loginAttempts, clientIp) {
1471
- const prev = loginAttempts.get(clientIp);
1472
- const count = (prev?.count ?? 0) + 1;
1473
- loginAttempts.recordFailure(clientIp);
1474
- return count;
1475
- }
1476
- async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, loginAttempts, dataDir, secureCookies, applyAuthFile, trustedProxyHops) {
1477
- if (!mutableAuth.cookieSecret) {
1478
- res.writeHead(503, { "Content-Type": "application/json" });
1479
- res.end(JSON.stringify({ error: { code: "NO_COOKIE_SECRET" } }));
1480
- return;
1481
- }
1482
- const clientIp = resolveClientAddress(req, trustedProxyHops);
1483
- const { blocked, retryAfter } = loginAttempts.checkBlocked(clientIp);
1484
- if (blocked) {
1485
- res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
1486
- res.end(
1487
- JSON.stringify({
1488
- error: { code: "RATE_LIMITED", message: "Too many attempts. Try again later." }
1489
- })
1490
- );
1491
- return;
1492
- }
1493
- const raw = readHqSessionCookie(req.headers.cookie);
1494
- const sessionId = raw ? parseHqSessionCookie(raw, mutableAuth.cookieSecret) : void 0;
1495
- if (!sessionId) {
1496
- res.writeHead(401, { "Content-Type": "application/json" });
1497
- res.end(
1498
- JSON.stringify({ error: { code: "NO_PENDING_SESSION", message: "No pending 2FA session." } })
1274
+ JSON.stringify({ error: { code: "NO_PENDING_SESSION", message: "No pending 2FA session." } })
1499
1275
  );
1500
1276
  return;
1501
1277
  }
@@ -1555,7 +1331,7 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1555
1331
  mutableAuth.totpLastUsedCounter = matchedCounter;
1556
1332
  loginAttempts.clearOnSuccess(clientIp);
1557
1333
  sessions.delete(sessionId);
1558
- const fullSessionId = randomUUID();
1334
+ const fullSessionId = randomUUID2();
1559
1335
  sessions.set(fullSessionId, {
1560
1336
  createdAt: Date.now(),
1561
1337
  kind: "password",
@@ -1603,7 +1379,7 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1603
1379
  releaseLock = resolve2;
1604
1380
  });
1605
1381
  try {
1606
- const next = await mutateHqAuthFile(dataDir, (current) => {
1382
+ const next = await mutateHqAuthFile2(dataDir, (current) => {
1607
1383
  const diskHashes = current.totpRecoveryCodes ?? [];
1608
1384
  const idx = diskHashes.indexOf(usedHash);
1609
1385
  if (idx === -1) {
@@ -1634,7 +1410,7 @@ async function handleApiLoginVerify(req, res, _url, mutableAuth, sessions, login
1634
1410
  }
1635
1411
  loginAttempts.clearOnSuccess(clientIp);
1636
1412
  sessions.delete(sessionId);
1637
- const fullSessionId = randomUUID();
1413
+ const fullSessionId = randomUUID2();
1638
1414
  sessions.set(fullSessionId, {
1639
1415
  createdAt: Date.now(),
1640
1416
  kind: "password",
@@ -1672,7 +1448,7 @@ async function handleApiTotpSetup(req, res, mutableAuth, sessions, dataDir, appl
1672
1448
  }
1673
1449
  const secret = generateTotpSecret();
1674
1450
  const uri = buildOtpAuthUri(secret, "HQ");
1675
- const next = await mutateHqAuthFile(dataDir, (current) => ({
1451
+ const next = await mutateHqAuthFile2(dataDir, (current) => ({
1676
1452
  ...current,
1677
1453
  totpPendingSecret: secret
1678
1454
  }));
@@ -1723,7 +1499,7 @@ async function handleApiTotpEnable(req, res, mutableAuth, sessions, dataDir, app
1723
1499
  }
1724
1500
  const recoveryCodes = generateRecoveryCodes();
1725
1501
  const recoveryHashes = recoveryCodes.map(hashRecoveryCode);
1726
- const next = await mutateHqAuthFile(dataDir, (current) => {
1502
+ const next = await mutateHqAuthFile2(dataDir, (current) => {
1727
1503
  const updated = { ...current };
1728
1504
  updated.totpSecret = pendingSecret;
1729
1505
  delete updated.totpPendingSecret;
@@ -1758,7 +1534,7 @@ async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttemp
1758
1534
  }
1759
1535
  let confirmed = false;
1760
1536
  if (typeof body.password === "string" && body.password.length > 0 && mutableAuth.passwordHash) {
1761
- confirmed = await verifyHqPassword(body.password, mutableAuth.passwordHash);
1537
+ confirmed = await verifyHqPassword2(body.password, mutableAuth.passwordHash);
1762
1538
  }
1763
1539
  if (!confirmed && typeof body.code === "string" && mutableAuth.totpSecret) {
1764
1540
  confirmed = verifyTotp(body.code, mutableAuth.totpSecret);
@@ -1777,7 +1553,7 @@ async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttemp
1777
1553
  return;
1778
1554
  }
1779
1555
  loginAttempts.delete(clientIp);
1780
- const next = await mutateHqAuthFile(dataDir, (current) => {
1556
+ const next = await mutateHqAuthFile2(dataDir, (current) => {
1781
1557
  const updated = { ...current };
1782
1558
  delete updated.totpSecret;
1783
1559
  delete updated.totpPendingSecret;
@@ -1788,13 +1564,144 @@ async function handleApiTotpDisable(req, res, mutableAuth, sessions, loginAttemp
1788
1564
  res.writeHead(200, { "Content-Type": "application/json" });
1789
1565
  res.end(JSON.stringify({ enabled: false }));
1790
1566
  }
1567
+
1568
+ // src/hq-server/routes/auth/bootstrap-routes.ts
1569
+ import { randomUUID as randomUUID3 } from "node:crypto";
1570
+ async function handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore) {
1571
+ if (!mutableAuth.cookieSecret) {
1572
+ res.writeHead(503, { "Content-Type": "application/json" });
1573
+ res.end(
1574
+ JSON.stringify({
1575
+ error: {
1576
+ code: "BOOTSTRAP_UNAVAILABLE",
1577
+ message: "Cookie signing is not configured on this HQ server."
1578
+ }
1579
+ })
1580
+ );
1581
+ return;
1582
+ }
1583
+ let body;
1584
+ try {
1585
+ body = JSON.parse(await readRequestBody(req));
1586
+ } catch (error) {
1587
+ writeInvalidBody(res, error);
1588
+ return;
1589
+ }
1590
+ if (typeof body.code !== "string" || body.code.length === 0) {
1591
+ res.writeHead(400, { "Content-Type": "application/json" });
1592
+ res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "code is required" } }));
1593
+ return;
1594
+ }
1595
+ const entry = bootstrapStore.consume(body.code);
1596
+ if (!entry) {
1597
+ res.writeHead(401, { "Content-Type": "application/json" });
1598
+ res.end(
1599
+ JSON.stringify({
1600
+ error: {
1601
+ code: "INVALID_OR_EXPIRED_CODE",
1602
+ message: "The bootstrap code is invalid, already used, or expired."
1603
+ }
1604
+ })
1605
+ );
1606
+ return;
1607
+ }
1608
+ const tokenLive = [...mutableAuth.browserTokenObjs.values()].some(
1609
+ (obj) => obj.id === entry.tokenId
1610
+ );
1611
+ if (!tokenLive) {
1612
+ res.writeHead(401, { "Content-Type": "application/json" });
1613
+ res.end(
1614
+ JSON.stringify({
1615
+ error: {
1616
+ code: "TOKEN_REVOKED",
1617
+ message: "The token that issued this bootstrap code has been revoked."
1618
+ }
1619
+ })
1620
+ );
1621
+ return;
1622
+ }
1623
+ const sessionId = randomUUID3();
1624
+ sessions.set(sessionId, {
1625
+ createdAt: Date.now(),
1626
+ lastSeenAt: Date.now(),
1627
+ kind: "token",
1628
+ tokenId: entry.tokenId,
1629
+ ...entry.capabilities !== void 0 ? { capabilities: entry.capabilities } : {}
1630
+ });
1631
+ setHqSessionCookie(
1632
+ res,
1633
+ serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
1634
+ secureCookies
1635
+ );
1636
+ res.writeHead(200, { "Content-Type": "application/json" });
1637
+ res.end(JSON.stringify({ loggedIn: true }));
1638
+ }
1639
+ async function handleApiTokenUpgrade(req, res, url, mutableAuth, sessions, secureCookies) {
1640
+ if (!mutableAuth.cookieSecret) {
1641
+ res.writeHead(503, { "Content-Type": "application/json" });
1642
+ res.end(
1643
+ JSON.stringify({
1644
+ error: {
1645
+ code: "UPGRADE_UNAVAILABLE",
1646
+ message: "Cookie signing is not configured on this HQ server."
1647
+ }
1648
+ })
1649
+ );
1650
+ return;
1651
+ }
1652
+ const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
1653
+ if (isCookieAuth(auth)) {
1654
+ res.writeHead(200, { "Content-Type": "application/json" });
1655
+ res.end(JSON.stringify({ loggedIn: true, upgraded: false }));
1656
+ return;
1657
+ }
1658
+ if (!isTokenAuth(auth)) {
1659
+ res.writeHead(401, { "Content-Type": "application/json" });
1660
+ res.end(
1661
+ JSON.stringify({
1662
+ error: { code: "UNAUTHORIZED", message: "A valid browser token is required." }
1663
+ })
1664
+ );
1665
+ return;
1666
+ }
1667
+ const tokenLive = [...mutableAuth.browserTokenObjs.values()].some((obj) => obj.id === auth.id);
1668
+ if (!tokenLive) {
1669
+ res.writeHead(409, { "Content-Type": "application/json" });
1670
+ res.end(
1671
+ JSON.stringify({
1672
+ error: {
1673
+ code: "TOKEN_NOT_UPGRADEABLE",
1674
+ message: "This token has no server-side record to bind a session to."
1675
+ }
1676
+ })
1677
+ );
1678
+ return;
1679
+ }
1680
+ const sessionId = randomUUID3();
1681
+ sessions.set(sessionId, {
1682
+ createdAt: Date.now(),
1683
+ lastSeenAt: Date.now(),
1684
+ kind: "token",
1685
+ tokenId: auth.id,
1686
+ ...auth.capabilities !== void 0 ? { capabilities: auth.capabilities } : {}
1687
+ });
1688
+ setHqSessionCookie(
1689
+ res,
1690
+ serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
1691
+ secureCookies
1692
+ );
1693
+ res.writeHead(200, { "Content-Type": "application/json" });
1694
+ res.end(JSON.stringify({ loggedIn: true, upgraded: true }));
1695
+ }
1696
+
1697
+ // src/hq-server/routes/auth/session-audit-routes.ts
1698
+ import { readHqAuthAuditTail } from "@wrongstack/core/hq";
1791
1699
  function handleApiAuthSessions(_req, res, mutableAuth, sessions) {
1792
1700
  const now = Date.now();
1793
1701
  const list = [];
1794
1702
  for (const [id, session] of sessions) {
1795
1703
  if (session.pending2fa) continue;
1796
1704
  list.push({
1797
- // The client sends the full ID back to revoke; `shortId` is for display.
1798
1705
  id,
1799
1706
  shortId: id.slice(0, 8),
1800
1707
  kind: session.kind,
@@ -1842,244 +1749,12 @@ function handleApiAuthAudit(_req, res, dataDir) {
1842
1749
  res.end(JSON.stringify({ entries }));
1843
1750
  }
1844
1751
 
1845
- // src/hq-server/routes/session-handlers.ts
1846
- import * as path4 from "node:path";
1752
+ // src/hq-server/routes/command-handlers.ts
1753
+ import { randomUUID as randomUUID4 } from "node:crypto";
1847
1754
  import {
1848
- buildTranscriptFromEvents,
1849
- resolveHqDataDir as resolveHqDataDir3
1755
+ tokenHasCapability,
1756
+ validateHqCommand
1850
1757
  } from "@wrongstack/core/hq";
1851
- async function handleApiSessions(res) {
1852
- const { getSessionRegistry } = await import("@wrongstack/core/storage");
1853
- const globalRoot = path4.dirname(resolveHqDataDir3());
1854
- try {
1855
- const registry = getSessionRegistry(globalRoot);
1856
- const sessions = await registry.list();
1857
- const result = sessions.filter((s) => s.status !== "stale").map((s) => ({
1858
- sessionId: s.sessionId,
1859
- projectSlug: s.projectSlug,
1860
- projectName: s.projectName,
1861
- projectRoot: s.projectRoot,
1862
- workingDir: s.workingDir,
1863
- status: s.status,
1864
- pid: s.pid,
1865
- startedAt: s.startedAt,
1866
- lastHeartbeatAt: s.lastHeartbeatAt,
1867
- agentCount: s.agentCount,
1868
- agents: s.agents.map((a) => ({
1869
- id: a.id,
1870
- name: a.name,
1871
- status: a.status,
1872
- currentTool: a.currentTool,
1873
- iterations: a.iterations,
1874
- toolCalls: a.toolCalls,
1875
- lastActivityAt: a.lastActivityAt
1876
- }))
1877
- }));
1878
- res.writeHead(200, { "Content-Type": "application/json" });
1879
- res.end(JSON.stringify(result));
1880
- } catch (err) {
1881
- res.writeHead(500, { "Content-Type": "application/json" });
1882
- res.end(JSON.stringify({ error: sanitizeApiError(err) }));
1883
- }
1884
- }
1885
- async function handleApiSessionEvents(req, res, match, transcripts) {
1886
- const { getSessionRegistry, DefaultSessionStore } = await import("@wrongstack/core/storage");
1887
- const { resolveWstackPaths } = await import("@wrongstack/core/utils");
1888
- const url = new URL(req.url ?? "/", "http://localhost");
1889
- const full = url.searchParams.get("full") === "1";
1890
- const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
1891
- const limit = Math.min(5e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
1892
- const sessionId = decodeSessionId(match[1]);
1893
- if (sessionId === null) {
1894
- res.writeHead(400, { "Content-Type": "application/json" });
1895
- res.end(JSON.stringify({ error: "invalid sessionId encoding" }));
1896
- return;
1897
- }
1898
- const globalRoot = path4.dirname(resolveHqDataDir3());
1899
- try {
1900
- const registry = getSessionRegistry(globalRoot);
1901
- const entry = await registry.get(sessionId).catch(() => null);
1902
- let entries = [];
1903
- let source = "stream";
1904
- let status;
1905
- let clientType;
1906
- let projectName;
1907
- if (entry && "projectRoot" in entry) {
1908
- const paths = resolveWstackPaths({
1909
- projectRoot: entry.projectRoot,
1910
- globalRoot
1911
- });
1912
- const store = new DefaultSessionStore({
1913
- dir: paths.projectSessions,
1914
- projectRoot: entry.projectRoot
1915
- });
1916
- const data = await store.load(sessionId).catch(() => null);
1917
- if (data) {
1918
- entries = buildTranscriptFromEvents(
1919
- data.events.map((e) => e)
1920
- );
1921
- source = "disk";
1922
- status = entry.status;
1923
- clientType = entry.clientType;
1924
- projectName = entry.projectName;
1925
- }
1926
- }
1927
- if (entries.length === 0) {
1928
- const ring = transcripts.get(sessionId);
1929
- if (!ring && !entry) {
1930
- res.writeHead(404, { "Content-Type": "application/json" });
1931
- res.end(JSON.stringify({ error: "Session not found" }));
1932
- return;
1933
- }
1934
- entries = ring ? ring.entries : [];
1935
- source = "stream";
1936
- if (entry) {
1937
- status = entry.status;
1938
- clientType = entry.clientType;
1939
- projectName = entry.projectName;
1940
- }
1941
- }
1942
- const total = entries.length;
1943
- const tail = full ? entries : entries.slice(-limit);
1944
- res.writeHead(200, { "Content-Type": "application/json" });
1945
- res.end(
1946
- JSON.stringify({
1947
- sessionId,
1948
- source,
1949
- ...status !== void 0 ? { status } : {},
1950
- ...clientType !== void 0 ? { clientType } : {},
1951
- ...projectName !== void 0 ? { projectName } : {},
1952
- total,
1953
- entries: tail
1954
- })
1955
- );
1956
- } catch (err) {
1957
- res.writeHead(500, { "Content-Type": "application/json" });
1958
- res.end(JSON.stringify({ error: sanitizeApiError(err) }));
1959
- }
1960
- }
1961
- async function handleApiSessionAgentMessages(req, res, match, agentMessages) {
1962
- const url = new URL(req.url ?? "/", "http://localhost");
1963
- const sid = decodeSessionId(match[1]);
1964
- const aid = decodePathSegment(match[2]);
1965
- if (sid === null || aid === null) {
1966
- res.writeHead(400, { "Content-Type": "application/json" });
1967
- res.end(JSON.stringify({ error: "invalid session or agent id encoding" }));
1968
- return;
1969
- }
1970
- const full = url.searchParams.get("full") === "1";
1971
- const disk = await readLocalSubagentTranscript(sid, aid);
1972
- const source = disk !== null ? "disk" : "stream";
1973
- const all = disk !== null ? disk : agentMessages.get(agentRingKey(sid, aid)) ?? agentMessages.get(aid) ?? [];
1974
- const entries = full ? all : all.slice(-200);
1975
- res.writeHead(200, { "Content-Type": "application/json" });
1976
- res.end(JSON.stringify({ subagentId: aid, sessionId: sid, source, total: all.length, entries }));
1977
- }
1978
- async function handleApiAgentMessages(req, res, match, agentMessages) {
1979
- const url = new URL(req.url ?? "/", "http://localhost");
1980
- const id = decodePathSegment(match[1]);
1981
- if (id === null) {
1982
- res.writeHead(400, { "Content-Type": "application/json" });
1983
- res.end(JSON.stringify({ error: "invalid agent id encoding" }));
1984
- return;
1985
- }
1986
- const full = url.searchParams.get("full") === "1";
1987
- const merged = [];
1988
- for (const [key, ring] of agentMessages) {
1989
- if (key === id || key.endsWith(`::${id}`)) merged.push(...ring);
1990
- }
1991
- merged.sort((a, b) => a.ts.localeCompare(b.ts));
1992
- const entries = full ? merged : merged.slice(-200);
1993
- res.writeHead(200, { "Content-Type": "application/json" });
1994
- res.end(JSON.stringify({ subagentId: id, total: merged.length, entries }));
1995
- }
1996
-
1997
- // src/hq-server/routes/system-handlers.ts
1998
- async function handleApiSystemUpdate(res) {
1999
- const [{ checkForUpdate }, { detectUpdatePackageName }] = await Promise.all([
2000
- import("./update-check-WARHSVZA.js"),
2001
- import("./update-VZSOZGYC.js")
2002
- ]);
2003
- const packageName = detectUpdatePackageName();
2004
- const info = await checkForUpdate({ packageName });
2005
- res.writeHead(200, {
2006
- "Content-Type": "application/json",
2007
- "Cache-Control": "no-store"
2008
- });
2009
- res.end(
2010
- JSON.stringify({
2011
- ...info,
2012
- packageName,
2013
- command: "wstack update"
2014
- })
2015
- );
2016
- }
2017
- async function handleApiSystemHealth(res, clients, persistence, eventLog) {
2018
- const now = Date.now();
2019
- const eventCount = eventLog.length;
2020
- let timeseriesStoreStatus = "unknown";
2021
- let kanbanStoreStatus = "unknown";
2022
- try {
2023
- const tsOk = await persistence.timeseries.read(now - 3e5);
2024
- timeseriesStoreStatus = Array.isArray(tsOk) ? "ok" : "degraded";
2025
- } catch {
2026
- timeseriesStoreStatus = "degraded";
2027
- }
2028
- try {
2029
- await persistence.kanban.load("__hq_health_probe__");
2030
- kanbanStoreStatus = "ok";
2031
- } catch {
2032
- kanbanStoreStatus = "degraded";
2033
- }
2034
- const inMemoryEventLogActive = true;
2035
- const allOk = inMemoryEventLogActive && timeseriesStoreStatus === "ok" && kanbanStoreStatus === "ok";
2036
- const clientCount = clients.size;
2037
- const connectedClients = [...clients.values()].filter(
2038
- (c) => Date.now() - new Date(c.lastSeenAt).getTime() < 6e4
2039
- ).length;
2040
- res.writeHead(200, {
2041
- "Content-Type": "application/json",
2042
- "Cache-Control": "no-store"
2043
- });
2044
- res.end(
2045
- JSON.stringify({
2046
- status: allOk ? "healthy" : "degraded",
2047
- uptime: {
2048
- serverTime: (/* @__PURE__ */ new Date()).toISOString(),
2049
- eventLogSize: eventCount
2050
- },
2051
- stores: {
2052
- events: "ok",
2053
- timeseries: timeseriesStoreStatus,
2054
- kanban: kanbanStoreStatus
2055
- },
2056
- connections: {
2057
- total: clientCount,
2058
- active: connectedClients,
2059
- stale: clientCount - connectedClients
2060
- }
2061
- })
2062
- );
2063
- }
2064
- async function handleApiCommandsAudit(req, res, auditLog) {
2065
- const url = new URL(req.url ?? "/", "http://localhost");
2066
- const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
2067
- const limit = Math.min(1e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
2068
- res.writeHead(200, { "Content-Type": "application/json" });
2069
- res.end(JSON.stringify({ commands: auditLog.recent(limit) }));
2070
- }
2071
- async function handleApiAlerts(req, res, alertEngine) {
2072
- const url = new URL(req.url ?? "/", "http://localhost");
2073
- const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
2074
- const limit = Math.min(500, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 100));
2075
- res.writeHead(200, { "Content-Type": "application/json" });
2076
- res.end(
2077
- JSON.stringify({
2078
- active: alertEngine.activeAlerts(),
2079
- history: alertEngine.recentAlerts(limit)
2080
- })
2081
- );
2082
- }
2083
1758
 
2084
1759
  // src/hq-server/snapshot.ts
2085
1760
  import { WebSocket } from "ws";
@@ -2566,33 +2241,691 @@ async function authorizeHqCommand(input) {
2566
2241
  return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason };
2567
2242
  }
2568
2243
 
2569
- // src/hq-server/routes.ts
2570
- var setHqSecurityHeaders2 = setHqSecurityHeaders;
2571
- var hasTrustedBrowserOrigin2 = hasTrustedBrowserOrigin;
2572
- var authenticateBrowserRequest2 = authenticateBrowserRequest;
2573
- var isTokenAuth2 = isTokenAuth;
2574
- var isCookieAuth2 = isCookieAuth;
2575
- var hqAuthRequired2 = hqAuthRequired;
2576
- var parseHqSessionCookie2 = parseHqSessionCookie;
2577
- var decodePathSegment2 = decodePathSegment;
2578
- var readRequestBody2 = readRequestBody;
2579
- var writeInvalidBody2 = writeInvalidBody;
2580
- var sanitizeApiError2 = sanitizeApiError;
2581
- var buildHttpUrl2 = buildHttpUrl;
2582
- var buildBootstrapHttpUrl2 = buildBootstrapHttpUrl;
2583
- var buildClientWsUrl2 = buildClientWsUrl;
2584
- var agentRingKey2 = agentRingKey;
2585
- var agentMessageToEntry2 = agentMessageToEntry;
2586
- var readLocalSubagentTranscript2 = readLocalSubagentTranscript;
2587
- function createHqRouter(deps) {
2588
- const {
2589
- host,
2590
- listeningPort,
2591
- trustedPublicOrigins,
2592
- allowFileOrigin,
2593
- mutableAuth,
2594
- sessions,
2595
- loginAttempts,
2244
+ // src/hq-server/routes/mailbox-handlers.ts
2245
+ import * as path4 from "node:path";
2246
+ import {
2247
+ resolveProjectDir
2248
+ } from "@wrongstack/core/coordination";
2249
+
2250
+ // src/hq-server/project-root.ts
2251
+ import { deriveHqProjectId } from "@wrongstack/core/hq";
2252
+ function resolveHqProjectRoot(globalRoot, ids) {
2253
+ const fn = async () => {
2254
+ const { getSessionRegistry } = await import("@wrongstack/core/storage");
2255
+ try {
2256
+ const registry = getSessionRegistry(globalRoot);
2257
+ if (typeof ids.sessionId === "string") {
2258
+ const entry = await registry.get(ids.sessionId).catch(() => null);
2259
+ if (entry?.projectRoot) return entry.projectRoot;
2260
+ }
2261
+ if (typeof ids.projectId === "string") {
2262
+ const { createHash: createHash2 } = await import("node:crypto");
2263
+ const all = await registry.list().catch(() => []);
2264
+ const projectIds = /* @__PURE__ */ new Map();
2265
+ const projectIdForRoot = (projectRoot) => {
2266
+ const cached = projectIds.get(projectRoot);
2267
+ if (cached !== void 0) return cached;
2268
+ const derived = deriveHqProjectId(projectRoot);
2269
+ projectIds.set(projectRoot, derived);
2270
+ return derived;
2271
+ };
2272
+ const match = all.find(
2273
+ (e) => e.projectSlug === ids.projectId || projectIdForRoot(e.projectRoot) === ids.projectId || createHash2("sha256").update(e.projectRoot).digest("hex").slice(0, 12) === ids.projectId
2274
+ );
2275
+ if (match) return match.projectRoot;
2276
+ }
2277
+ } catch {
2278
+ }
2279
+ return void 0;
2280
+ };
2281
+ return fn();
2282
+ }
2283
+
2284
+ // src/hq-server/routes/mailbox-handlers.ts
2285
+ function callerCanEnqueue(auth, mutableAuth) {
2286
+ if (isCookieAuth(auth)) {
2287
+ return auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue");
2288
+ }
2289
+ if (isTokenAuth(auth)) {
2290
+ return auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue");
2291
+ }
2292
+ return !hqAuthRequired(mutableAuth);
2293
+ }
2294
+ async function handleMailboxGateway(_req, res, match, authorizeMailboxGateway, getMailboxGateway, dataDir) {
2295
+ const projectId = decodePathSegment(match[1]);
2296
+ if (projectId === null || projectId.length === 0) {
2297
+ res.writeHead(400, { "Content-Type": "application/json" });
2298
+ res.end(
2299
+ JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid projectId encoding" } })
2300
+ );
2301
+ return;
2302
+ }
2303
+ const gatewayGlobalRoot = path4.dirname(dataDir);
2304
+ const preliminaryAccess = authorizeMailboxGateway(_req, `project:${projectId}`);
2305
+ if (!preliminaryAccess.allowed) {
2306
+ res.writeHead(preliminaryAccess.status ?? 401, {
2307
+ "Content-Type": "application/json; charset=utf-8",
2308
+ "Cache-Control": "no-store"
2309
+ });
2310
+ res.end(JSON.stringify(preliminaryAccess.body));
2311
+ return;
2312
+ }
2313
+ const projectRoot = await resolveHqProjectRoot(gatewayGlobalRoot, { projectId });
2314
+ if (!projectRoot) {
2315
+ res.writeHead(404, { "Content-Type": "application/json" });
2316
+ res.end(
2317
+ JSON.stringify({ error: { code: "NOT_FOUND", message: `Unknown project: ${projectId}` } })
2318
+ );
2319
+ return;
2320
+ }
2321
+ const suffix = match[2];
2322
+ const rawUrl = _req.url ?? "";
2323
+ const queryIndex = rawUrl.indexOf("?");
2324
+ const querySuffix = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
2325
+ const canonicalPath = suffix ? `/mailbox/${suffix}${querySuffix}` : `/mailbox${querySuffix}`;
2326
+ const projectDir = resolveProjectDir(projectRoot, gatewayGlobalRoot);
2327
+ await getMailboxGateway(projectDir).router.handle(_req, res, canonicalPath);
2328
+ }
2329
+ async function handleApiMailboxSend(_req, res, mutableAuth, sessions, dataDir, getMailboxGateway, hqSessionTag) {
2330
+ const auth = authenticateBrowserRequest(
2331
+ _req,
2332
+ new URL(_req.url ?? "/", "http://localhost"),
2333
+ mutableAuth,
2334
+ sessions
2335
+ );
2336
+ if (hqAuthRequired(mutableAuth) && !auth) {
2337
+ res.writeHead(401, { "Content-Type": "application/json" });
2338
+ res.end(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "unauthorized" } }));
2339
+ return;
2340
+ }
2341
+ const canEnqueue = callerCanEnqueue(auth, mutableAuth);
2342
+ if (!canEnqueue) {
2343
+ res.writeHead(403, { "Content-Type": "application/json" });
2344
+ res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
2345
+ return;
2346
+ }
2347
+ let mbody;
2348
+ try {
2349
+ mbody = JSON.parse(await readRequestBody(_req));
2350
+ } catch (error) {
2351
+ writeInvalidBody(res, error);
2352
+ return;
2353
+ }
2354
+ if (typeof mbody.type !== "string" || typeof mbody.body !== "string") {
2355
+ res.writeHead(400, { "Content-Type": "application/json" });
2356
+ res.end(JSON.stringify({ error: "missing type or body" }));
2357
+ return;
2358
+ }
2359
+ if (typeof mbody.sessionId !== "string" && typeof mbody.projectId !== "string") {
2360
+ res.writeHead(400, { "Content-Type": "application/json" });
2361
+ res.end(JSON.stringify({ error: "missing sessionId or projectId" }));
2362
+ return;
2363
+ }
2364
+ if (mbody.audience !== void 0 && mbody.audience !== "all" && mbody.audience !== "leaders") {
2365
+ res.writeHead(400, { "Content-Type": "application/json" });
2366
+ res.end(JSON.stringify({ error: "audience must be all or leaders" }));
2367
+ return;
2368
+ }
2369
+ const mbGlobalRoot = path4.dirname(dataDir);
2370
+ const projectRoot = await resolveHqProjectRoot(mbGlobalRoot, {
2371
+ sessionId: mbody.sessionId,
2372
+ projectId: mbody.projectId
2373
+ });
2374
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) {
2375
+ res.writeHead(404, { "Content-Type": "application/json" });
2376
+ res.end(JSON.stringify({ error: "could not resolve target project mailbox" }));
2377
+ return;
2378
+ }
2379
+ const to = typeof mbody.to === "string" ? mbody.to : "leader";
2380
+ const subject = typeof mbody.subject === "string" ? mbody.subject : "HQ prompt";
2381
+ const priority = mbody.priority === "high" ? "high" : mbody.priority === "low" ? "low" : "normal";
2382
+ const audience = mbody.audience === "leaders" ? "leaders" : "all";
2383
+ const mailboxType = mbody.type === "queue" ? "note" : [
2384
+ "note",
2385
+ "ask",
2386
+ "assign",
2387
+ "steer",
2388
+ "btw",
2389
+ "broadcast",
2390
+ "status",
2391
+ "result",
2392
+ "review"
2393
+ ].includes(mbody.type) ? mbody.type : void 0;
2394
+ if (mailboxType === void 0) {
2395
+ res.writeHead(400, { "Content-Type": "application/json" });
2396
+ res.end(
2397
+ JSON.stringify({
2398
+ error: "unrecognized or malformed mailbox message",
2399
+ type: mbody.type
2400
+ })
2401
+ );
2402
+ return;
2403
+ }
2404
+ if ((mailboxType === "assign" || mailboxType === "steer") && (to === "*" || to.trim().toLowerCase() === "all")) {
2405
+ res.writeHead(400, { "Content-Type": "application/json" });
2406
+ res.end(JSON.stringify({ error: `${mailboxType} requires a specific recipient` }));
2407
+ return;
2408
+ }
2409
+ try {
2410
+ const projectDir = resolveProjectDir(projectRoot, mbGlobalRoot);
2411
+ const mailbox = getMailboxGateway(projectDir).mailbox;
2412
+ const from = `hq@${hqSessionTag}`;
2413
+ const deliveryTo = mailboxType === "broadcast" ? "all" : to;
2414
+ const sent = await mailbox.send({
2415
+ from,
2416
+ to: deliveryTo,
2417
+ type: mailboxType,
2418
+ subject,
2419
+ body: mbody.body,
2420
+ priority,
2421
+ audience
2422
+ });
2423
+ res.writeHead(202, { "Content-Type": "application/json" });
2424
+ res.end(
2425
+ JSON.stringify({
2426
+ delivered: true,
2427
+ messageId: sent?.id,
2428
+ to: sent?.to ?? deliveryTo,
2429
+ type: mailboxType,
2430
+ audience
2431
+ })
2432
+ );
2433
+ } catch (err) {
2434
+ res.writeHead(500, { "Content-Type": "application/json" });
2435
+ res.end(JSON.stringify({ error: "mailbox write failed", detail: sanitizeApiError(err) }));
2436
+ }
2437
+ }
2438
+ async function handleMailboxAction(_req, res, match, mutableAuth, sessions, dataDir, getMailboxGateway) {
2439
+ const auth = authenticateBrowserRequest(
2440
+ _req,
2441
+ new URL(_req.url ?? "/", "http://localhost"),
2442
+ mutableAuth,
2443
+ sessions
2444
+ );
2445
+ if (hqAuthRequired(mutableAuth) && !auth) {
2446
+ res.writeHead(401, { "Content-Type": "application/json" });
2447
+ res.end(JSON.stringify({ error: "unauthorized" }));
2448
+ return;
2449
+ }
2450
+ if (!callerCanEnqueue(auth, mutableAuth)) {
2451
+ res.writeHead(403, { "Content-Type": "application/json" });
2452
+ res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
2453
+ return;
2454
+ }
2455
+ const MAILBOX_ACTIONS = ["mark-read", "acknowledge", "reopen", "soft-delete", "restore"];
2456
+ let abody;
2457
+ try {
2458
+ abody = JSON.parse(await readRequestBody(_req));
2459
+ } catch (error) {
2460
+ writeInvalidBody(res, error);
2461
+ return;
2462
+ }
2463
+ const mailId = decodePathSegment(match[1]);
2464
+ if (mailId === null) {
2465
+ res.writeHead(400, { "Content-Type": "application/json" });
2466
+ res.end(JSON.stringify({ error: "invalid mailId encoding" }));
2467
+ return;
2468
+ }
2469
+ const action = MAILBOX_ACTIONS.find((a) => a === abody.action);
2470
+ if (action === void 0) {
2471
+ res.writeHead(400, { "Content-Type": "application/json" });
2472
+ res.end(JSON.stringify({ error: "unrecognized action", action: abody.action }));
2473
+ return;
2474
+ }
2475
+ const authenticatedReaderId = isCookieAuth(auth) ? auth.tokenId ?? "hq-password-session" : isTokenAuth(auth) ? auth.id : void 0;
2476
+ const resolvedReaderId = authenticatedReaderId ?? abody.readerId;
2477
+ if (typeof resolvedReaderId !== "string" || resolvedReaderId.length === 0) {
2478
+ res.writeHead(400, { "Content-Type": "application/json" });
2479
+ res.end(JSON.stringify({ error: "missing readerId" }));
2480
+ return;
2481
+ }
2482
+ if (typeof abody.sessionId !== "string" && typeof abody.projectId !== "string") {
2483
+ res.writeHead(400, { "Content-Type": "application/json" });
2484
+ res.end(JSON.stringify({ error: "missing sessionId or projectId" }));
2485
+ return;
2486
+ }
2487
+ const actGlobalRoot = path4.dirname(dataDir);
2488
+ const projectRoot = await resolveHqProjectRoot(actGlobalRoot, {
2489
+ sessionId: abody.sessionId,
2490
+ projectId: abody.projectId
2491
+ });
2492
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) {
2493
+ res.writeHead(404, { "Content-Type": "application/json" });
2494
+ res.end(JSON.stringify({ error: "could not resolve target project mailbox" }));
2495
+ return;
2496
+ }
2497
+ try {
2498
+ const { actionToAckInput } = await import("@wrongstack/core/coordination");
2499
+ const projectDir = resolveProjectDir(projectRoot, actGlobalRoot);
2500
+ const mailbox = getMailboxGateway(projectDir).mailbox;
2501
+ const readerId = resolvedReaderId;
2502
+ const message = action === "soft-delete" ? await mailbox.softDelete(mailId, readerId) : action === "restore" ? await mailbox.restore(mailId) : await mailbox.ack(actionToAckInput(action, { action, mailId, readerId }));
2503
+ if (message === null) {
2504
+ res.writeHead(404, { "Content-Type": "application/json" });
2505
+ res.end(JSON.stringify({ error: "message not found", mailId }));
2506
+ return;
2507
+ }
2508
+ res.writeHead(200, { "Content-Type": "application/json" });
2509
+ res.end(JSON.stringify({ action, mailId, message: null, changed: true }));
2510
+ } catch (err) {
2511
+ res.writeHead(500, { "Content-Type": "application/json" });
2512
+ res.end(
2513
+ JSON.stringify({
2514
+ error: "mailbox action failed",
2515
+ detail: sanitizeApiError(err)
2516
+ })
2517
+ );
2518
+ }
2519
+ }
2520
+
2521
+ // src/hq-server/routes/command-handlers.ts
2522
+ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, browsers, auditLog, trustBoundary) {
2523
+ const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
2524
+ if (hqAuthRequired(mutableAuth) && !auth) {
2525
+ res.writeHead(401, { "Content-Type": "application/json" });
2526
+ res.end(JSON.stringify({ error: "unauthorized" }));
2527
+ return;
2528
+ }
2529
+ const canEnqueue = callerCanEnqueue(auth, mutableAuth);
2530
+ if (!canEnqueue) {
2531
+ res.writeHead(403, { "Content-Type": "application/json" });
2532
+ res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
2533
+ return;
2534
+ }
2535
+ let body;
2536
+ try {
2537
+ body = JSON.parse(await readRequestBody(req));
2538
+ } catch (error) {
2539
+ writeInvalidBody(res, error);
2540
+ return;
2541
+ }
2542
+ if (typeof body.clientId !== "string" || typeof body.type !== "string") {
2543
+ res.writeHead(400, { "Content-Type": "application/json" });
2544
+ res.end(JSON.stringify({ error: "missing clientId or type" }));
2545
+ return;
2546
+ }
2547
+ let target;
2548
+ for (const c of clients.values()) {
2549
+ if (c.clientId === body.clientId) {
2550
+ target = c;
2551
+ break;
2552
+ }
2553
+ }
2554
+ if (target === void 0) {
2555
+ res.writeHead(404, { "Content-Type": "application/json" });
2556
+ res.end(JSON.stringify({ error: "client not connected", clientId: body.clientId }));
2557
+ return;
2558
+ }
2559
+ if (!target.capabilities.includes("control.receive")) {
2560
+ res.writeHead(409, { "Content-Type": "application/json" });
2561
+ res.end(
2562
+ JSON.stringify({
2563
+ error: "client does not accept control commands",
2564
+ clientId: body.clientId
2565
+ })
2566
+ );
2567
+ return;
2568
+ }
2569
+ const commandId = randomUUID4();
2570
+ const queued = {
2571
+ commandId,
2572
+ type: body.type,
2573
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2574
+ payload: body.payload ?? {},
2575
+ requiresAck: true
2576
+ };
2577
+ const validated = validateHqCommand(queued);
2578
+ if (validated === null) {
2579
+ res.writeHead(400, { "Content-Type": "application/json" });
2580
+ res.end(JSON.stringify({ error: "unrecognized or malformed command", type: body.type }));
2581
+ return;
2582
+ }
2583
+ if (validated.type === "run-command" && !tokenHasCapability(target.authToken, "control.execute")) {
2584
+ res.writeHead(403, { "Content-Type": "application/json" });
2585
+ res.end(
2586
+ JSON.stringify({
2587
+ error: "forbidden: target client token lacks control.execute capability"
2588
+ })
2589
+ );
2590
+ return;
2591
+ }
2592
+ const enqueuedBy = isCookieAuth(auth) ? auth.tokenId ?? "password-session" : isTokenAuth(auth) ? auth.id : "open-mode";
2593
+ const authorization = await authorizeHqCommand({
2594
+ boundary: trustBoundary,
2595
+ command: validated,
2596
+ commandId,
2597
+ enqueuedBy,
2598
+ authMethod: isCookieAuth(auth) ? "session" : "bearer-token",
2599
+ target
2600
+ });
2601
+ if (!authorization.allowed) {
2602
+ res.writeHead(403, { "Content-Type": "application/json" });
2603
+ res.end(JSON.stringify({ error: `command denied: ${authorization.reason}` }));
2604
+ return;
2605
+ }
2606
+ target.commandQueue.push(queued);
2607
+ if (target.commandQueue.length > 200)
2608
+ target.commandQueue.splice(0, target.commandQueue.length - 200);
2609
+ const auditEntry = {
2610
+ commandId,
2611
+ type: validated.type,
2612
+ clientId: target.clientId,
2613
+ enqueuedBy,
2614
+ enqueuedAt: queued.createdAt,
2615
+ status: "queued"
2616
+ };
2617
+ auditLog.record(auditEntry);
2618
+ broadcastCommandStatus(auditEntry, browsers);
2619
+ res.writeHead(202, { "Content-Type": "application/json" });
2620
+ res.end(JSON.stringify({ commandId, queued: true, clientId: target.clientId }));
2621
+ }
2622
+
2623
+ // src/hq-server/routes/data-handlers.ts
2624
+ async function handleApiProjectDetail(_req, res, url, clients) {
2625
+ const projectId = decodePathSegment(url.pathname.slice("/api/projects/".length));
2626
+ if (projectId === null) {
2627
+ res.writeHead(400, { "Content-Type": "application/json" });
2628
+ res.end(
2629
+ JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid projectId encoding" } })
2630
+ );
2631
+ return;
2632
+ }
2633
+ if (!projectId) {
2634
+ res.writeHead(400, { "Content-Type": "application/json" });
2635
+ res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "projectId is required" } }));
2636
+ return;
2637
+ }
2638
+ const detail = buildProjectDetail(clients, projectId);
2639
+ if (!detail) {
2640
+ res.writeHead(404, { "Content-Type": "application/json" });
2641
+ res.end(
2642
+ JSON.stringify({ error: { code: "NOT_FOUND", message: `Unknown project: ${projectId}` } })
2643
+ );
2644
+ return;
2645
+ }
2646
+ res.writeHead(200, { "Content-Type": "application/json" });
2647
+ res.end(JSON.stringify(detail));
2648
+ }
2649
+ async function handleApiEvents(req, res, persistence) {
2650
+ const url = new URL(req.url ?? "/", "http://localhost");
2651
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
2652
+ const limit = Math.min(5e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
2653
+ const typeFilter = url.searchParams.get("type") ?? void 0;
2654
+ const events = await persistence.eventLog.recent(limit, typeFilter);
2655
+ res.writeHead(200, { "Content-Type": "application/json" });
2656
+ res.end(JSON.stringify({ events, total: events.length }));
2657
+ }
2658
+ async function handleApiTrendsCost(req, res, persistence) {
2659
+ const url = new URL(req.url ?? "/", "http://localhost");
2660
+ const rawSince = Number.parseInt(url.searchParams.get("since") ?? "0", 10);
2661
+ const since = Number.isFinite(rawSince) ? rawSince : 0;
2662
+ const samples = await persistence.timeseries.read(since);
2663
+ res.writeHead(200, { "Content-Type": "application/json" });
2664
+ res.end(JSON.stringify({ samples }));
2665
+ }
2666
+
2667
+ // src/hq-server/routes/session-handlers.ts
2668
+ import * as path5 from "node:path";
2669
+ import {
2670
+ buildTranscriptFromEvents,
2671
+ resolveHqDataDir as resolveHqDataDir2
2672
+ } from "@wrongstack/core/hq";
2673
+ async function handleApiSessions(res) {
2674
+ const { getSessionRegistry } = await import("@wrongstack/core/storage");
2675
+ const globalRoot = path5.dirname(resolveHqDataDir2());
2676
+ try {
2677
+ const registry = getSessionRegistry(globalRoot);
2678
+ const sessions = await registry.list();
2679
+ const result = sessions.filter((s) => s.status !== "stale").map((s) => ({
2680
+ sessionId: s.sessionId,
2681
+ projectSlug: s.projectSlug,
2682
+ projectName: s.projectName,
2683
+ projectRoot: s.projectRoot,
2684
+ workingDir: s.workingDir,
2685
+ status: s.status,
2686
+ pid: s.pid,
2687
+ startedAt: s.startedAt,
2688
+ lastHeartbeatAt: s.lastHeartbeatAt,
2689
+ agentCount: s.agentCount,
2690
+ agents: s.agents.map((a) => ({
2691
+ id: a.id,
2692
+ name: a.name,
2693
+ status: a.status,
2694
+ currentTool: a.currentTool,
2695
+ iterations: a.iterations,
2696
+ toolCalls: a.toolCalls,
2697
+ lastActivityAt: a.lastActivityAt
2698
+ }))
2699
+ }));
2700
+ res.writeHead(200, { "Content-Type": "application/json" });
2701
+ res.end(JSON.stringify(result));
2702
+ } catch (err) {
2703
+ res.writeHead(500, { "Content-Type": "application/json" });
2704
+ res.end(JSON.stringify({ error: sanitizeApiError(err) }));
2705
+ }
2706
+ }
2707
+ async function handleApiSessionEvents(req, res, match, transcripts) {
2708
+ const { getSessionRegistry, DefaultSessionStore } = await import("@wrongstack/core/storage");
2709
+ const { resolveWstackPaths } = await import("@wrongstack/core/utils");
2710
+ const url = new URL(req.url ?? "/", "http://localhost");
2711
+ const full = url.searchParams.get("full") === "1";
2712
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
2713
+ const limit = Math.min(5e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
2714
+ const sessionId = decodeSessionId(match[1]);
2715
+ if (sessionId === null) {
2716
+ res.writeHead(400, { "Content-Type": "application/json" });
2717
+ res.end(JSON.stringify({ error: "invalid sessionId encoding" }));
2718
+ return;
2719
+ }
2720
+ const globalRoot = path5.dirname(resolveHqDataDir2());
2721
+ try {
2722
+ const registry = getSessionRegistry(globalRoot);
2723
+ const entry = await registry.get(sessionId).catch(() => null);
2724
+ let entries = [];
2725
+ let source = "stream";
2726
+ let status;
2727
+ let clientType;
2728
+ let projectName;
2729
+ if (entry && "projectRoot" in entry) {
2730
+ const paths = resolveWstackPaths({
2731
+ projectRoot: entry.projectRoot,
2732
+ globalRoot
2733
+ });
2734
+ const store = new DefaultSessionStore({
2735
+ dir: paths.projectSessions,
2736
+ projectRoot: entry.projectRoot
2737
+ });
2738
+ const data = await store.load(sessionId).catch(() => null);
2739
+ if (data) {
2740
+ entries = buildTranscriptFromEvents(
2741
+ data.events.map((e) => e)
2742
+ );
2743
+ source = "disk";
2744
+ status = entry.status;
2745
+ clientType = entry.clientType;
2746
+ projectName = entry.projectName;
2747
+ }
2748
+ }
2749
+ if (entries.length === 0) {
2750
+ const ring = transcripts.get(sessionId);
2751
+ if (!ring && !entry) {
2752
+ res.writeHead(404, { "Content-Type": "application/json" });
2753
+ res.end(JSON.stringify({ error: "Session not found" }));
2754
+ return;
2755
+ }
2756
+ entries = ring ? ring.entries : [];
2757
+ source = "stream";
2758
+ if (entry) {
2759
+ status = entry.status;
2760
+ clientType = entry.clientType;
2761
+ projectName = entry.projectName;
2762
+ }
2763
+ }
2764
+ const total = entries.length;
2765
+ const tail = full ? entries : entries.slice(-limit);
2766
+ res.writeHead(200, { "Content-Type": "application/json" });
2767
+ res.end(
2768
+ JSON.stringify({
2769
+ sessionId,
2770
+ source,
2771
+ ...status !== void 0 ? { status } : {},
2772
+ ...clientType !== void 0 ? { clientType } : {},
2773
+ ...projectName !== void 0 ? { projectName } : {},
2774
+ total,
2775
+ entries: tail
2776
+ })
2777
+ );
2778
+ } catch (err) {
2779
+ res.writeHead(500, { "Content-Type": "application/json" });
2780
+ res.end(JSON.stringify({ error: sanitizeApiError(err) }));
2781
+ }
2782
+ }
2783
+ async function handleApiSessionAgentMessages(req, res, match, agentMessages) {
2784
+ const url = new URL(req.url ?? "/", "http://localhost");
2785
+ const sid = decodeSessionId(match[1]);
2786
+ const aid = decodePathSegment(match[2]);
2787
+ if (sid === null || aid === null) {
2788
+ res.writeHead(400, { "Content-Type": "application/json" });
2789
+ res.end(JSON.stringify({ error: "invalid session or agent id encoding" }));
2790
+ return;
2791
+ }
2792
+ const full = url.searchParams.get("full") === "1";
2793
+ const disk = await readLocalSubagentTranscript(sid, aid);
2794
+ const source = disk !== null ? "disk" : "stream";
2795
+ const all = disk !== null ? disk : agentMessages.get(agentRingKey(sid, aid)) ?? agentMessages.get(aid) ?? [];
2796
+ const entries = full ? all : all.slice(-200);
2797
+ res.writeHead(200, { "Content-Type": "application/json" });
2798
+ res.end(JSON.stringify({ subagentId: aid, sessionId: sid, source, total: all.length, entries }));
2799
+ }
2800
+ async function handleApiAgentMessages(req, res, match, agentMessages) {
2801
+ const url = new URL(req.url ?? "/", "http://localhost");
2802
+ const id = decodePathSegment(match[1]);
2803
+ if (id === null) {
2804
+ res.writeHead(400, { "Content-Type": "application/json" });
2805
+ res.end(JSON.stringify({ error: "invalid agent id encoding" }));
2806
+ return;
2807
+ }
2808
+ const full = url.searchParams.get("full") === "1";
2809
+ const merged = [];
2810
+ for (const [key, ring] of agentMessages) {
2811
+ if (key === id || key.endsWith(`::${id}`)) merged.push(...ring);
2812
+ }
2813
+ merged.sort((a, b) => a.ts.localeCompare(b.ts));
2814
+ const entries = full ? merged : merged.slice(-200);
2815
+ res.writeHead(200, { "Content-Type": "application/json" });
2816
+ res.end(JSON.stringify({ subagentId: id, total: merged.length, entries }));
2817
+ }
2818
+
2819
+ // src/hq-server/routes/system-handlers.ts
2820
+ async function handleApiSystemUpdate(res) {
2821
+ const [{ checkForUpdate }, { detectUpdatePackageName }] = await Promise.all([
2822
+ import("./update-check-WARHSVZA.js"),
2823
+ import("./update-VZSOZGYC.js")
2824
+ ]);
2825
+ const packageName = detectUpdatePackageName();
2826
+ const info = await checkForUpdate({ packageName });
2827
+ res.writeHead(200, {
2828
+ "Content-Type": "application/json",
2829
+ "Cache-Control": "no-store"
2830
+ });
2831
+ res.end(
2832
+ JSON.stringify({
2833
+ ...info,
2834
+ packageName,
2835
+ command: "wstack update"
2836
+ })
2837
+ );
2838
+ }
2839
+ async function handleApiSystemHealth(res, clients, persistence, eventLog) {
2840
+ const now = Date.now();
2841
+ const eventCount = eventLog.length;
2842
+ let timeseriesStoreStatus = "unknown";
2843
+ let kanbanStoreStatus = "unknown";
2844
+ try {
2845
+ const tsOk = await persistence.timeseries.read(now - 3e5);
2846
+ timeseriesStoreStatus = Array.isArray(tsOk) ? "ok" : "degraded";
2847
+ } catch {
2848
+ timeseriesStoreStatus = "degraded";
2849
+ }
2850
+ try {
2851
+ await persistence.kanban.load("__hq_health_probe__");
2852
+ kanbanStoreStatus = "ok";
2853
+ } catch {
2854
+ kanbanStoreStatus = "degraded";
2855
+ }
2856
+ const inMemoryEventLogActive = true;
2857
+ const allOk = inMemoryEventLogActive && timeseriesStoreStatus === "ok" && kanbanStoreStatus === "ok";
2858
+ const clientCount = clients.size;
2859
+ const connectedClients = [...clients.values()].filter(
2860
+ (c) => Date.now() - new Date(c.lastSeenAt).getTime() < 6e4
2861
+ ).length;
2862
+ res.writeHead(200, {
2863
+ "Content-Type": "application/json",
2864
+ "Cache-Control": "no-store"
2865
+ });
2866
+ res.end(
2867
+ JSON.stringify({
2868
+ status: allOk ? "healthy" : "degraded",
2869
+ uptime: {
2870
+ serverTime: (/* @__PURE__ */ new Date()).toISOString(),
2871
+ eventLogSize: eventCount
2872
+ },
2873
+ stores: {
2874
+ events: "ok",
2875
+ timeseries: timeseriesStoreStatus,
2876
+ kanban: kanbanStoreStatus
2877
+ },
2878
+ connections: {
2879
+ total: clientCount,
2880
+ active: connectedClients,
2881
+ stale: clientCount - connectedClients
2882
+ }
2883
+ })
2884
+ );
2885
+ }
2886
+ async function handleApiCommandsAudit(req, res, auditLog) {
2887
+ const url = new URL(req.url ?? "/", "http://localhost");
2888
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
2889
+ const limit = Math.min(1e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
2890
+ res.writeHead(200, { "Content-Type": "application/json" });
2891
+ res.end(JSON.stringify({ commands: auditLog.recent(limit) }));
2892
+ }
2893
+ async function handleApiAlerts(req, res, alertEngine) {
2894
+ const url = new URL(req.url ?? "/", "http://localhost");
2895
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
2896
+ const limit = Math.min(500, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 100));
2897
+ res.writeHead(200, { "Content-Type": "application/json" });
2898
+ res.end(
2899
+ JSON.stringify({
2900
+ active: alertEngine.activeAlerts(),
2901
+ history: alertEngine.recentAlerts(limit)
2902
+ })
2903
+ );
2904
+ }
2905
+
2906
+ // src/hq-server/routes.ts
2907
+ var hasTrustedBrowserOrigin2 = hasTrustedBrowserOrigin;
2908
+ var authenticateBrowserRequest2 = authenticateBrowserRequest;
2909
+ var isTokenAuth2 = isTokenAuth;
2910
+ var isCookieAuth2 = isCookieAuth;
2911
+ var parseHqSessionCookie2 = parseHqSessionCookie;
2912
+ var decodePathSegment2 = decodePathSegment;
2913
+ var sanitizeApiError2 = sanitizeApiError;
2914
+ var buildHttpUrl2 = buildHttpUrl;
2915
+ var buildBootstrapHttpUrl2 = buildBootstrapHttpUrl;
2916
+ var buildClientWsUrl2 = buildClientWsUrl;
2917
+ var agentRingKey2 = agentRingKey;
2918
+ var agentMessageToEntry2 = agentMessageToEntry;
2919
+ var readLocalSubagentTranscript2 = readLocalSubagentTranscript;
2920
+ function createHqRouter(deps) {
2921
+ const {
2922
+ host,
2923
+ listeningPort,
2924
+ trustedPublicOrigins,
2925
+ allowFileOrigin,
2926
+ mutableAuth,
2927
+ sessions,
2928
+ loginAttempts,
2596
2929
  clients,
2597
2930
  browsers,
2598
2931
  eventLog,
@@ -2616,14 +2949,20 @@ function createHqRouter(deps) {
2616
2949
  return async (req, res) => {
2617
2950
  try {
2618
2951
  const url = new URL(req.url ?? "/", `http://${host}:${listeningPort()}`);
2619
- setHqSecurityHeaders2(res);
2620
- if (!hasTrustedBrowserOrigin2(req, host, listeningPort(), trustedPublicOrigins, allowFileOrigin)) {
2952
+ setHqSecurityHeaders(res);
2953
+ if (!hasTrustedBrowserOrigin(
2954
+ req,
2955
+ host,
2956
+ listeningPort(),
2957
+ trustedPublicOrigins,
2958
+ allowFileOrigin
2959
+ )) {
2621
2960
  res.writeHead(403, { "Content-Type": "application/json" });
2622
2961
  res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
2623
2962
  return;
2624
2963
  }
2625
- if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && url.pathname !== "/api/login/verify" && url.pathname !== "/api/auth/bootstrap" && hqAuthRequired2(mutableAuth, requireBrowserAuth)) {
2626
- const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
2964
+ if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && url.pathname !== "/api/login/verify" && url.pathname !== "/api/auth/bootstrap" && hqAuthRequired(mutableAuth, requireBrowserAuth)) {
2965
+ const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
2627
2966
  if (!auth) {
2628
2967
  const tokenOnly = mutableAuth.browserTokens.size > 0 && !mutableAuth.passwordHash;
2629
2968
  res.writeHead(401, { "Content-Type": "application/json" });
@@ -2663,7 +3002,7 @@ function createHqRouter(deps) {
2663
3002
  return;
2664
3003
  }
2665
3004
  if (url.pathname === "/api/auth/audit" && req.method === "GET" || url.pathname.startsWith("/api/auth/sessions") && (req.method === "GET" || req.method === "DELETE") || url.pathname.startsWith("/api/auth/totp/") && req.method === "POST") {
2666
- const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
3005
+ const auth = authenticateBrowserRequest(req, url, mutableAuth, sessions);
2667
3006
  if (auth !== void 0 && !callerCanAdministerAuth(auth)) {
2668
3007
  res.writeHead(403, { "Content-Type": "application/json" });
2669
3008
  res.end(
@@ -2807,7 +3146,7 @@ function createHqRouter(deps) {
2807
3146
  }
2808
3147
  const projectKanbanMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/kanban$/);
2809
3148
  if (projectKanbanMatch && req.method === "GET") {
2810
- const projectId = decodePathSegment2(projectKanbanMatch[1]);
3149
+ const projectId = decodePathSegment(projectKanbanMatch[1]);
2811
3150
  if (projectId === null) {
2812
3151
  res.writeHead(400, { "Content-Type": "application/json" });
2813
3152
  res.end(
@@ -2894,415 +3233,187 @@ function createHqRouter(deps) {
2894
3233
  }
2895
3234
  const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
2896
3235
  if (eventsMatch && req.method === "GET") {
2897
- await handleApiSessionEvents(req, res, eventsMatch, transcripts);
2898
- return;
2899
- }
2900
- const sessionAgentMsgMatch = url.pathname.match(
2901
- /^\/api\/sessions\/([^/]+)\/agents\/([^/]+)\/messages$/
2902
- );
2903
- if (sessionAgentMsgMatch && req.method === "GET") {
2904
- await handleApiSessionAgentMessages(req, res, sessionAgentMsgMatch, agentMessages);
2905
- return;
2906
- }
2907
- const agentMsgMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/messages$/);
2908
- if (agentMsgMatch && req.method === "GET") {
2909
- await handleApiAgentMessages(req, res, agentMsgMatch, agentMessages);
2910
- return;
2911
- }
2912
- res.writeHead(404, { "Content-Type": "text/plain" });
2913
- res.end("Not found");
2914
- } catch (err) {
2915
- console.error(
2916
- JSON.stringify({
2917
- level: "error",
2918
- event: "hq.http_handler_error",
2919
- message: String(err),
2920
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2921
- })
2922
- );
2923
- if (!res.headersSent) {
2924
- res.writeHead(500, { "Content-Type": "application/json" });
2925
- res.end(JSON.stringify({ error: "Internal server error" }));
2926
- }
2927
- }
2928
- };
2929
- }
2930
- async function handleMailboxGateway(_req, res, match, authorizeMailboxGateway, getMailboxGateway, dataDir) {
2931
- const projectId = decodePathSegment2(match[1]);
2932
- if (projectId === null || projectId.length === 0) {
2933
- res.writeHead(400, { "Content-Type": "application/json" });
2934
- res.end(
2935
- JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid projectId encoding" } })
2936
- );
2937
- return;
2938
- }
2939
- const gatewayGlobalRoot = path5.dirname(dataDir);
2940
- const preliminaryAccess = authorizeMailboxGateway(_req, `project:${projectId}`);
2941
- if (!preliminaryAccess.allowed) {
2942
- res.writeHead(preliminaryAccess.status ?? 401, {
2943
- "Content-Type": "application/json; charset=utf-8",
2944
- "Cache-Control": "no-store"
2945
- });
2946
- res.end(JSON.stringify(preliminaryAccess.body));
2947
- return;
2948
- }
2949
- const projectRoot = await resolveHqProjectRoot(gatewayGlobalRoot, { projectId });
2950
- if (!projectRoot) {
2951
- res.writeHead(404, { "Content-Type": "application/json" });
2952
- res.end(
2953
- JSON.stringify({ error: { code: "NOT_FOUND", message: `Unknown project: ${projectId}` } })
2954
- );
2955
- return;
2956
- }
2957
- const suffix = match[2];
2958
- const rawUrl = _req.url ?? "";
2959
- const queryIndex = rawUrl.indexOf("?");
2960
- const querySuffix = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
2961
- const canonicalPath = suffix ? `/mailbox/${suffix}${querySuffix}` : `/mailbox${querySuffix}`;
2962
- const projectDir = resolveProjectDir(projectRoot, gatewayGlobalRoot);
2963
- await getMailboxGateway(projectDir).router.handle(_req, res, canonicalPath);
2964
- }
2965
- async function handleApiProjectDetail(_req, res, url, clients) {
2966
- const projectId = decodePathSegment2(url.pathname.slice("/api/projects/".length));
2967
- if (projectId === null) {
2968
- res.writeHead(400, { "Content-Type": "application/json" });
2969
- res.end(
2970
- JSON.stringify({ error: { code: "BAD_REQUEST", message: "invalid projectId encoding" } })
2971
- );
2972
- return;
2973
- }
2974
- if (!projectId) {
2975
- res.writeHead(400, { "Content-Type": "application/json" });
2976
- res.end(JSON.stringify({ error: { code: "BAD_REQUEST", message: "projectId is required" } }));
2977
- return;
2978
- }
2979
- const detail = buildProjectDetail(clients, projectId);
2980
- if (!detail) {
2981
- res.writeHead(404, { "Content-Type": "application/json" });
2982
- res.end(
2983
- JSON.stringify({ error: { code: "NOT_FOUND", message: `Unknown project: ${projectId}` } })
2984
- );
2985
- return;
2986
- }
2987
- res.writeHead(200, { "Content-Type": "application/json" });
2988
- res.end(JSON.stringify(detail));
2989
- }
2990
- async function handleApiEvents(req, res, persistence) {
2991
- const url = new URL(req.url ?? "/", "http://localhost");
2992
- const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
2993
- const limit = Math.min(5e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
2994
- const typeFilter = url.searchParams.get("type") ?? void 0;
2995
- const events = await persistence.eventLog.recent(limit, typeFilter);
2996
- res.writeHead(200, { "Content-Type": "application/json" });
2997
- res.end(JSON.stringify({ events, total: events.length }));
2998
- }
2999
- async function handleApiTrendsCost(req, res, persistence) {
3000
- const url = new URL(req.url ?? "/", "http://localhost");
3001
- const rawSince = Number.parseInt(url.searchParams.get("since") ?? "0", 10);
3002
- const since = Number.isFinite(rawSince) ? rawSince : 0;
3003
- const samples = await persistence.timeseries.read(since);
3004
- res.writeHead(200, { "Content-Type": "application/json" });
3005
- res.end(JSON.stringify({ samples }));
3006
- }
3007
- async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, browsers, auditLog, trustBoundary) {
3008
- const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
3009
- if (hqAuthRequired2(mutableAuth) && !auth) {
3010
- res.writeHead(401, { "Content-Type": "application/json" });
3011
- res.end(JSON.stringify({ error: "unauthorized" }));
3012
- return;
3013
- }
3014
- const canEnqueue = callerCanEnqueue(auth, mutableAuth);
3015
- if (!canEnqueue) {
3016
- res.writeHead(403, { "Content-Type": "application/json" });
3017
- res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
3018
- return;
3019
- }
3020
- let body;
3021
- try {
3022
- body = JSON.parse(await readRequestBody2(req));
3023
- } catch (error) {
3024
- writeInvalidBody2(res, error);
3025
- return;
3026
- }
3027
- if (typeof body.clientId !== "string" || typeof body.type !== "string") {
3028
- res.writeHead(400, { "Content-Type": "application/json" });
3029
- res.end(JSON.stringify({ error: "missing clientId or type" }));
3030
- return;
3031
- }
3032
- let target;
3033
- for (const c of clients.values()) {
3034
- if (c.clientId === body.clientId) {
3035
- target = c;
3036
- break;
3236
+ await handleApiSessionEvents(req, res, eventsMatch, transcripts);
3237
+ return;
3238
+ }
3239
+ const sessionAgentMsgMatch = url.pathname.match(
3240
+ /^\/api\/sessions\/([^/]+)\/agents\/([^/]+)\/messages$/
3241
+ );
3242
+ if (sessionAgentMsgMatch && req.method === "GET") {
3243
+ await handleApiSessionAgentMessages(req, res, sessionAgentMsgMatch, agentMessages);
3244
+ return;
3245
+ }
3246
+ const agentMsgMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/messages$/);
3247
+ if (agentMsgMatch && req.method === "GET") {
3248
+ await handleApiAgentMessages(req, res, agentMsgMatch, agentMessages);
3249
+ return;
3250
+ }
3251
+ res.writeHead(404, { "Content-Type": "text/plain" });
3252
+ res.end("Not found");
3253
+ } catch (err) {
3254
+ console.error(
3255
+ JSON.stringify({
3256
+ level: "error",
3257
+ event: "hq.http_handler_error",
3258
+ message: String(err),
3259
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3260
+ })
3261
+ );
3262
+ if (!res.headersSent) {
3263
+ res.writeHead(500, { "Content-Type": "application/json" });
3264
+ res.end(JSON.stringify({ error: "Internal server error" }));
3265
+ }
3037
3266
  }
3038
- }
3039
- if (target === void 0) {
3040
- res.writeHead(404, { "Content-Type": "application/json" });
3041
- res.end(JSON.stringify({ error: "client not connected", clientId: body.clientId }));
3042
- return;
3043
- }
3044
- if (!target.capabilities.includes("control.receive")) {
3045
- res.writeHead(409, { "Content-Type": "application/json" });
3046
- res.end(
3047
- JSON.stringify({
3048
- error: "client does not accept control commands",
3049
- clientId: body.clientId
3050
- })
3051
- );
3052
- return;
3053
- }
3054
- const commandId = randomUUID2();
3055
- const queued = {
3056
- commandId,
3057
- type: body.type,
3058
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3059
- payload: body.payload ?? {},
3060
- requiresAck: true
3061
- };
3062
- const validated = validateHqCommand(queued);
3063
- if (validated === null) {
3064
- res.writeHead(400, { "Content-Type": "application/json" });
3065
- res.end(JSON.stringify({ error: "unrecognized or malformed command", type: body.type }));
3066
- return;
3067
- }
3068
- if (validated.type === "run-command" && !tokenHasCapability(target.authToken, "control.execute")) {
3069
- res.writeHead(403, { "Content-Type": "application/json" });
3070
- res.end(
3071
- JSON.stringify({
3072
- error: "forbidden: target client token lacks control.execute capability"
3073
- })
3074
- );
3075
- return;
3076
- }
3077
- const enqueuedBy = isCookieAuth2(auth) ? auth.tokenId ?? "password-session" : isTokenAuth2(auth) ? auth.id : "open-mode";
3078
- const authorization = await authorizeHqCommand({
3079
- boundary: trustBoundary,
3080
- command: validated,
3081
- commandId,
3082
- enqueuedBy,
3083
- authMethod: isCookieAuth2(auth) ? "session" : "bearer-token",
3084
- target
3085
- });
3086
- if (!authorization.allowed) {
3087
- res.writeHead(403, { "Content-Type": "application/json" });
3088
- res.end(JSON.stringify({ error: `command denied: ${authorization.reason}` }));
3089
- return;
3090
- }
3091
- target.commandQueue.push(queued);
3092
- if (target.commandQueue.length > 200)
3093
- target.commandQueue.splice(0, target.commandQueue.length - 200);
3094
- const auditEntry = {
3095
- commandId,
3096
- type: validated.type,
3097
- clientId: target.clientId,
3098
- enqueuedBy,
3099
- enqueuedAt: queued.createdAt,
3100
- status: "queued"
3101
3267
  };
3102
- auditLog.record(auditEntry);
3103
- broadcastCommandStatus(auditEntry, browsers);
3104
- res.writeHead(202, { "Content-Type": "application/json" });
3105
- res.end(JSON.stringify({ commandId, queued: true, clientId: target.clientId }));
3106
3268
  }
3107
- async function handleApiMailboxSend(_req, res, mutableAuth, sessions, dataDir, getMailboxGateway, hqSessionTag) {
3108
- const auth = authenticateBrowserRequest2(
3109
- _req,
3110
- new URL(_req.url ?? "/", "http://localhost"),
3111
- mutableAuth,
3112
- sessions
3113
- );
3114
- if (hqAuthRequired2(mutableAuth) && !auth) {
3115
- res.writeHead(401, { "Content-Type": "application/json" });
3116
- res.end(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "unauthorized" } }));
3117
- return;
3118
- }
3119
- const canEnqueue = callerCanEnqueue(auth, mutableAuth);
3120
- if (!canEnqueue) {
3121
- res.writeHead(403, { "Content-Type": "application/json" });
3122
- res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
3123
- return;
3124
- }
3125
- let mbody;
3126
- try {
3127
- mbody = JSON.parse(await readRequestBody2(_req));
3128
- } catch (error) {
3129
- writeInvalidBody2(res, error);
3130
- return;
3131
- }
3132
- if (typeof mbody.type !== "string" || typeof mbody.body !== "string") {
3133
- res.writeHead(400, { "Content-Type": "application/json" });
3134
- res.end(JSON.stringify({ error: "missing type or body" }));
3135
- return;
3136
- }
3137
- if (typeof mbody.sessionId !== "string" && typeof mbody.projectId !== "string") {
3138
- res.writeHead(400, { "Content-Type": "application/json" });
3139
- res.end(JSON.stringify({ error: "missing sessionId or projectId" }));
3140
- return;
3141
- }
3142
- if (mbody.audience !== void 0 && mbody.audience !== "all" && mbody.audience !== "leaders") {
3143
- res.writeHead(400, { "Content-Type": "application/json" });
3144
- res.end(JSON.stringify({ error: "audience must be all or leaders" }));
3145
- return;
3146
- }
3147
- const mbGlobalRoot = path5.dirname(dataDir);
3148
- const projectRoot = await resolveHqProjectRoot(mbGlobalRoot, {
3149
- sessionId: mbody.sessionId,
3150
- projectId: mbody.projectId
3151
- });
3152
- if (typeof projectRoot !== "string" || projectRoot.length === 0) {
3153
- res.writeHead(404, { "Content-Type": "application/json" });
3154
- res.end(JSON.stringify({ error: "could not resolve target project mailbox" }));
3155
- return;
3156
- }
3157
- const to = typeof mbody.to === "string" ? mbody.to : "leader";
3158
- const subject = typeof mbody.subject === "string" ? mbody.subject : "HQ prompt";
3159
- const priority = mbody.priority === "high" ? "high" : mbody.priority === "low" ? "low" : "normal";
3160
- const audience = mbody.audience === "leaders" ? "leaders" : "all";
3161
- const mailboxType = mbody.type === "queue" ? "note" : [
3162
- "note",
3163
- "ask",
3164
- "assign",
3165
- "steer",
3166
- "btw",
3167
- "broadcast",
3168
- "status",
3169
- "result",
3170
- "review"
3171
- ].includes(mbody.type) ? mbody.type : void 0;
3172
- if (mailboxType === void 0) {
3173
- res.writeHead(400, { "Content-Type": "application/json" });
3174
- res.end(
3175
- JSON.stringify({
3176
- error: "unrecognized or malformed mailbox message",
3177
- type: mbody.type
3178
- })
3269
+
3270
+ // src/hq-server/mailbox-gateway-manager.ts
3271
+ var MailboxGatewayManager = class {
3272
+ constructor(deps) {
3273
+ this.deps = deps;
3274
+ this.rateLimitCleanupTimer = setInterval(
3275
+ () => this.mailboxGatewayRateLimiter.cleanup(),
3276
+ 12e4
3179
3277
  );
3180
- return;
3181
- }
3182
- if ((mailboxType === "assign" || mailboxType === "steer") && (to === "*" || to.trim().toLowerCase() === "all")) {
3183
- res.writeHead(400, { "Content-Type": "application/json" });
3184
- res.end(JSON.stringify({ error: `${mailboxType} requires a specific recipient` }));
3185
- return;
3186
- }
3187
- try {
3188
- const projectDir = resolveProjectDir(projectRoot, mbGlobalRoot);
3189
- const mailbox = getMailboxGateway(projectDir).mailbox;
3190
- const from = `hq@${hqSessionTag}`;
3191
- const deliveryTo = mailboxType === "broadcast" ? "all" : to;
3192
- const sent = await mailbox.send({
3193
- from,
3194
- to: deliveryTo,
3195
- type: mailboxType,
3196
- subject,
3197
- body: mbody.body,
3198
- priority,
3199
- audience
3200
- });
3201
- res.writeHead(202, { "Content-Type": "application/json" });
3202
- res.end(
3203
- JSON.stringify({
3204
- delivered: true,
3205
- messageId: sent?.id,
3206
- to: sent?.to ?? deliveryTo,
3207
- type: mailboxType,
3208
- audience
3209
- })
3278
+ this.rateLimitCleanupTimer.unref?.();
3279
+ this.sweepTimer = setInterval(
3280
+ () => this.evictIdleGateways(),
3281
+ this.MAILBOX_GATEWAY_SWEEP_INTERVAL_MS
3210
3282
  );
3211
- } catch (err) {
3212
- res.writeHead(500, { "Content-Type": "application/json" });
3213
- res.end(JSON.stringify({ error: "mailbox write failed", detail: sanitizeApiError2(err) }));
3214
- }
3215
- }
3216
- function callerCanEnqueue(auth, mutableAuth) {
3217
- if (isCookieAuth2(auth)) {
3218
- return auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue");
3219
- }
3220
- if (isTokenAuth2(auth)) {
3221
- return auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue");
3222
- }
3223
- return !hqAuthRequired2(mutableAuth);
3224
- }
3225
- async function handleMailboxAction(_req, res, match, mutableAuth, sessions, dataDir, getMailboxGateway) {
3226
- const auth = authenticateBrowserRequest2(
3227
- _req,
3228
- new URL(_req.url ?? "/", "http://localhost"),
3229
- mutableAuth,
3230
- sessions
3231
- );
3232
- if (hqAuthRequired2(mutableAuth) && !auth) {
3233
- res.writeHead(401, { "Content-Type": "application/json" });
3234
- res.end(JSON.stringify({ error: "unauthorized" }));
3235
- return;
3236
- }
3237
- if (!callerCanEnqueue(auth, mutableAuth)) {
3238
- res.writeHead(403, { "Content-Type": "application/json" });
3239
- res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
3240
- return;
3241
- }
3242
- const MAILBOX_ACTIONS = ["mark-read", "acknowledge", "reopen", "soft-delete", "restore"];
3243
- let abody;
3244
- try {
3245
- abody = JSON.parse(await readRequestBody2(_req));
3246
- } catch (error) {
3247
- writeInvalidBody2(res, error);
3248
- return;
3249
- }
3250
- const mailId = decodePathSegment2(match[1]);
3251
- if (mailId === null) {
3252
- res.writeHead(400, { "Content-Type": "application/json" });
3253
- res.end(JSON.stringify({ error: "invalid mailId encoding" }));
3254
- return;
3255
- }
3256
- const action = MAILBOX_ACTIONS.find((a) => a === abody.action);
3257
- if (action === void 0) {
3258
- res.writeHead(400, { "Content-Type": "application/json" });
3259
- res.end(JSON.stringify({ error: "unrecognized action", action: abody.action }));
3260
- return;
3261
- }
3262
- const authenticatedReaderId = isCookieAuth2(auth) ? auth.tokenId ?? "hq-password-session" : isTokenAuth2(auth) ? auth.id : void 0;
3263
- const resolvedReaderId = authenticatedReaderId ?? abody.readerId;
3264
- if (typeof resolvedReaderId !== "string" || resolvedReaderId.length === 0) {
3265
- res.writeHead(400, { "Content-Type": "application/json" });
3266
- res.end(JSON.stringify({ error: "missing readerId" }));
3267
- return;
3268
- }
3269
- if (typeof abody.sessionId !== "string" && typeof abody.projectId !== "string") {
3270
- res.writeHead(400, { "Content-Type": "application/json" });
3271
- res.end(JSON.stringify({ error: "missing sessionId or projectId" }));
3272
- return;
3283
+ this.sweepTimer.unref?.();
3284
+ }
3285
+ deps;
3286
+ mailboxGateways = /* @__PURE__ */ new Map();
3287
+ mailboxGatewayLastUsed = /* @__PURE__ */ new Map();
3288
+ mailboxGatewayRateLimiter = new MailboxHttpRateLimiter();
3289
+ rateLimitCleanupTimer;
3290
+ sweepTimer;
3291
+ MAILBOX_GATEWAY_IDLE_TTL_MS = 15 * 6e4;
3292
+ MAILBOX_GATEWAY_SWEEP_INTERVAL_MS = 6e4;
3293
+ authorizeMailboxGateway(req, projectDir) {
3294
+ const requestUrl = new URL(req.url ?? "/", `http://${this.deps.host}:${this.deps.port}`);
3295
+ const auth = authenticateBrowserRequest2(req, requestUrl, this.deps.mutableAuth, this.deps.sessions);
3296
+ const tokenMode = this.deps.mutableAuth.browserTokens.size > 0;
3297
+ if (hqAuthRequired(this.deps.mutableAuth) && auth === void 0) {
3298
+ return {
3299
+ allowed: false,
3300
+ status: 401,
3301
+ body: { error: { code: "UNAUTHORIZED", message: "unauthorized" } }
3302
+ };
3303
+ }
3304
+ const token = isTokenAuth2(auth) ? this.deps.mutableAuth.browserTokenObjs.get(auth.token) : void 0;
3305
+ const canUseMailbox = isCookieAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? token?.capabilities === void 0 || !!token?.capabilities.includes("control.enqueue") : !tokenMode;
3306
+ if (!canUseMailbox) {
3307
+ return {
3308
+ allowed: false,
3309
+ status: 403,
3310
+ body: {
3311
+ error: {
3312
+ code: "FORBIDDEN",
3313
+ message: "forbidden: token lacks control.enqueue capability"
3314
+ }
3315
+ }
3316
+ };
3317
+ }
3318
+ const identity = isTokenAuth2(auth) ? auth.id : isCookieAuth2(auth) ? "cookie" : "open";
3319
+ return { allowed: true, rateLimitKey: `hq:${identity}:${projectDir}` };
3320
+ }
3321
+ getMailboxGateway(projectDir) {
3322
+ const existing = this.mailboxGateways.get(projectDir);
3323
+ if (existing) {
3324
+ this.mailboxGatewayLastUsed.set(projectDir, Date.now());
3325
+ return existing;
3326
+ }
3327
+ const eventEmitter = new MailboxEventEmitter();
3328
+ const mailbox = createProjectMailbox({ projectDir, eventEmitter });
3329
+ const router = createMailboxHttpRouter({
3330
+ mailbox,
3331
+ eventEmitter,
3332
+ rateLimiter: this.mailboxGatewayRateLimiter,
3333
+ authorize: (request) => this.authorizeMailboxGateway(request, projectDir),
3334
+ defaultMaxAgeMs: MAILBOX_HTTP_DEFAULT_MAX_AGE_MS
3335
+ });
3336
+ const gateway = { mailbox, router };
3337
+ this.mailboxGateways.set(projectDir, gateway);
3338
+ this.mailboxGatewayLastUsed.set(projectDir, Date.now());
3339
+ return gateway;
3340
+ }
3341
+ evictIdleGateways() {
3342
+ const cutoff = Date.now() - this.MAILBOX_GATEWAY_IDLE_TTL_MS;
3343
+ for (const [projectDir, gateway] of [...this.mailboxGateways]) {
3344
+ if ((this.mailboxGatewayLastUsed.get(projectDir) ?? 0) > cutoff) continue;
3345
+ if (gateway.router.hasActiveStreams()) continue;
3346
+ this.mailboxGateways.delete(projectDir);
3347
+ this.mailboxGatewayLastUsed.delete(projectDir);
3348
+ gateway.router.close();
3349
+ void gateway.mailbox.close().catch(() => void 0);
3350
+ }
3273
3351
  }
3274
- const actGlobalRoot = path5.dirname(dataDir);
3275
- const projectRoot = await resolveHqProjectRoot(actGlobalRoot, {
3276
- sessionId: abody.sessionId,
3277
- projectId: abody.projectId
3352
+ close() {
3353
+ clearInterval(this.rateLimitCleanupTimer);
3354
+ clearInterval(this.sweepTimer);
3355
+ for (const { router } of this.mailboxGateways.values()) router.close();
3356
+ }
3357
+ };
3358
+
3359
+ // src/hq-server/preflight.ts
3360
+ import {
3361
+ assessHqExposure,
3362
+ ensureHqFirstRunAuthFile,
3363
+ HqInsecureExposureError,
3364
+ isTokenExpired as isTokenExpired3,
3365
+ resolveHqDataDir as resolveHqDataDir3
3366
+ } from "@wrongstack/core/hq";
3367
+ async function prepareHqServerStart(options, defaults) {
3368
+ const host = options.host ?? defaults.host;
3369
+ const port = options.port ?? defaults.port;
3370
+ const dataDir = resolveHqDataDir3(options.dataDir);
3371
+ const firstRunAuth = await ensureHqFirstRunAuthFile(dataDir, {
3372
+ warn: (message) => console.warn(
3373
+ JSON.stringify({
3374
+ level: "warn",
3375
+ event: "hq.auth_load_failed",
3376
+ message,
3377
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3378
+ })
3379
+ ),
3380
+ actor: resolveAuditActor(),
3381
+ ...options.password !== void 0 ? { password: options.password } : {},
3382
+ ...options.tokenTtlMs !== void 0 ? { tokenTtlMs: options.tokenTtlMs } : {}
3278
3383
  });
3279
- if (typeof projectRoot !== "string" || projectRoot.length === 0) {
3280
- res.writeHead(404, { "Content-Type": "application/json" });
3281
- res.end(JSON.stringify({ error: "could not resolve target project mailbox" }));
3282
- return;
3384
+ const { authFile } = firstRunAuth;
3385
+ const rawBrowserTokens = authFile.browserTokens ?? [];
3386
+ const liveBrowserTokens = rawBrowserTokens.filter((token) => !isTokenExpired3(token));
3387
+ const hasPassword = authFile.passwordHash !== void 0;
3388
+ const allTokensExpired = rawBrowserTokens.length > 0 && liveBrowserTokens.length === 0;
3389
+ if (options.requireBrowserAuth && liveBrowserTokens.length === 0 && !hasPassword) {
3390
+ throw new HqInsecureExposureError(
3391
+ allTokensExpired ? `HQ public relay requires browser authentication, but all ${rawBrowserTokens.length} browser token(s) in the auth file have expired. Create a fresh token or set --password.` : "HQ public relay requires browser authentication. Set --password or create a browser token first."
3392
+ );
3283
3393
  }
3284
- try {
3285
- const { actionToAckInput } = await import("@wrongstack/core/coordination");
3286
- const projectDir = resolveProjectDir(projectRoot, actGlobalRoot);
3287
- const mailbox = getMailboxGateway(projectDir).mailbox;
3288
- const readerId = resolvedReaderId;
3289
- const message = action === "soft-delete" ? await mailbox.softDelete(mailId, readerId) : action === "restore" ? await mailbox.restore(mailId) : await mailbox.ack(actionToAckInput(action, { action, mailId, readerId }));
3290
- if (message === null) {
3291
- res.writeHead(404, { "Content-Type": "application/json" });
3292
- res.end(JSON.stringify({ error: "message not found", mailId }));
3293
- return;
3294
- }
3295
- res.writeHead(200, { "Content-Type": "application/json" });
3296
- res.end(JSON.stringify({ action, mailId, message: null, changed: true }));
3297
- } catch (err) {
3298
- res.writeHead(500, { "Content-Type": "application/json" });
3299
- res.end(
3394
+ const exposure = assessHqExposure({
3395
+ host,
3396
+ hasBrowserTokens: liveBrowserTokens.length > 0,
3397
+ hasPassword,
3398
+ allowInsecure: options.allowInsecureOpen
3399
+ });
3400
+ if (exposure.kind === "refuse") {
3401
+ throw new HqInsecureExposureError(
3402
+ allTokensExpired ? `${exposure.message} (all ${rawBrowserTokens.length} browser token(s) in the auth file have expired)` : exposure.message
3403
+ );
3404
+ }
3405
+ if (exposure.kind === "warn") {
3406
+ console.warn(
3300
3407
  JSON.stringify({
3301
- error: "mailbox action failed",
3302
- detail: sanitizeApiError2(err)
3408
+ level: "warn",
3409
+ event: "hq.insecure_exposure",
3410
+ message: exposure.message,
3411
+ host,
3412
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3303
3413
  })
3304
3414
  );
3305
3415
  }
3416
+ return { host, port, dataDir, firstRunAuth };
3306
3417
  }
3307
3418
 
3308
3419
  // src/hq-server/startup.ts
@@ -3388,24 +3499,320 @@ ${terminalText("Reachable from other machines on your network:", "blue", { bold:
3388
3499
  );
3389
3500
  }
3390
3501
 
3502
+ // src/hq-server/server-lifecycle.ts
3503
+ function createHqServerShutdown(params) {
3504
+ let closed = false;
3505
+ return () => new Promise((res) => {
3506
+ if (closed) {
3507
+ res();
3508
+ return;
3509
+ }
3510
+ closed = true;
3511
+ clearInterval(params.cleanupTimer);
3512
+ clearInterval(params.browserHeartbeatTimer);
3513
+ clearInterval(params.timeseriesFlushTimer);
3514
+ clearInterval(params.sessionCleanupTimer);
3515
+ void params.loginAttempts.flush();
3516
+ params.stopAlertEngine();
3517
+ params.authWatcher.close();
3518
+ params.snapshotBroadcaster.close();
3519
+ params.bootstrapStore.clear();
3520
+ params.persistence.timeseries.flush();
3521
+ for (const ws of [...params.browsers, ...params.clients.keys()]) {
3522
+ try {
3523
+ ws.close(1001, "HQ shutting down");
3524
+ ws.terminate();
3525
+ } catch {
3526
+ }
3527
+ }
3528
+ params.wss.close();
3529
+ params.mailboxManager.close();
3530
+ params.httpServer.close(() => {
3531
+ const mailboxCloses = [...params.mailboxManager.mailboxGateways.values()].map(
3532
+ ({ mailbox }) => mailbox.close().catch(() => void 0)
3533
+ );
3534
+ params.mailboxManager.mailboxGateways.clear();
3535
+ params.mailboxManager.mailboxGatewayLastUsed.clear();
3536
+ void Promise.all([
3537
+ ...mailboxCloses,
3538
+ params.persistence.eventLog.drain(),
3539
+ params.persistence.snapshotStore.drain(),
3540
+ params.persistence.timeseries.drain(),
3541
+ params.persistence.kanban.drain()
3542
+ ]).then(() => clearHqRuntimeMarker(params.dataDir, params.hqUrl)).catch(() => void 0).finally(() => res());
3543
+ });
3544
+ });
3545
+ }
3546
+
3547
+ // src/hq-server/upgrade-handler.ts
3548
+ import { isLoopbackHost as isLoopbackHost3, hqTokenVerifier as hqTokenVerifier2 } from "@wrongstack/core/hq";
3549
+
3391
3550
  // src/hq-server/ws.ts
3392
- import { randomUUID as randomUUID3 } from "node:crypto";
3551
+ import { randomUUID as randomUUID5 } from "node:crypto";
3552
+ import {
3553
+ HQ_PROTOCOL_VERSION as HQ_PROTOCOL_VERSION2,
3554
+ parseHqFrame,
3555
+ resolveHqRedactionPolicy,
3556
+ tightenHqRedactionPolicy as tightenHqRedactionPolicy2,
3557
+ tokenHasCapability as tokenHasCapability3
3558
+ } from "@wrongstack/core/hq";
3559
+ import { WebSocket as WebSocket2 } from "ws";
3560
+
3561
+ // src/hq-server/ws-client-events.ts
3393
3562
  import {
3394
- HQ_BROWSER_PEER_RESUME_CLIENT_ID,
3395
- HQ_PROTOCOL_VERSION,
3396
- HQ_RESUME_GAP_MAX_BYTES,
3397
- HQ_RESUME_GAP_MAX_ENVELOPES,
3398
- HQ_RESUME_GAP_MAX_STALE_MS,
3399
3563
  HQ_TRANSCRIPT_TEXT_CAP,
3400
3564
  parseHqEventPayload,
3401
- parseHqFrame,
3402
3565
  redactHqEvent,
3403
- resolveHqRedactionPolicy,
3404
3566
  tightenHqRedactionPolicy,
3405
3567
  tokenHasCapability as tokenHasCapability2
3406
3568
  } from "@wrongstack/core/hq";
3407
- import { WebSocket as WebSocket2 } from "ws";
3569
+ function fanoutKanbanDelta(message, clients, _browsers, projectId) {
3570
+ let clientsNotified = 0;
3571
+ for (const peer of clients.values()) {
3572
+ if (peer.projectId === projectId) {
3573
+ sendGuarded(peer.ws, message);
3574
+ clientsNotified++;
3575
+ }
3576
+ }
3577
+ return { clientsNotified, browsersNotified: 0 };
3578
+ }
3579
+ function handleIncomingClientEvent(ws, incomingEvent, clients, browsers, auth, snapshotBroadcaster, transcripts, agentMessages, persistence, persistEvent) {
3580
+ const client = clients.get(ws);
3581
+ if (!client) return;
3582
+ if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
3583
+ ws.close(1008, "event identity mismatch");
3584
+ return;
3585
+ }
3586
+ if (!tokenHasCapability2(client.authToken, "telemetry.publish") && auth.token !== void 0) {
3587
+ ws.close(1008, "client token lacks telemetry.publish capability");
3588
+ return;
3589
+ }
3590
+ if (incomingEvent.seq <= client.lastEventSeq) {
3591
+ ws.close(1008, "event sequence must increase monotonically");
3592
+ return;
3593
+ }
3594
+ const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
3595
+ if (!parsedPayload.ok) {
3596
+ console.warn(
3597
+ JSON.stringify({
3598
+ level: "warn",
3599
+ event: "hq.event_payload_rejected",
3600
+ eventType: incomingEvent.type,
3601
+ clientId: client.clientId,
3602
+ projectId: client.projectId,
3603
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3604
+ })
3605
+ );
3606
+ return;
3607
+ }
3608
+ client.lastEventSeq = incomingEvent.seq;
3609
+ client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
3610
+ const event = redactHqEvent(
3611
+ { ...incomingEvent, payload: parsedPayload.payload },
3612
+ {
3613
+ policy: tightenHqRedactionPolicy(
3614
+ client.declaredRedactionPolicy,
3615
+ auth.getOperatorPolicy()
3616
+ ),
3617
+ projectRoot: client.project.projectRoot,
3618
+ // Chat-transcript events carry full turns — the generic 500-char
3619
+ // summary cap would truncate them a second time after the
3620
+ // publisher already applied the transcript cap.
3621
+ ...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
3622
+ }
3623
+ ).value;
3624
+ if (event.type === "client.heartbeat") {
3625
+ return;
3626
+ }
3627
+ if (event.type === "kanban.snapshot") {
3628
+ const payload = event.payload;
3629
+ if (payload.projectId !== client.projectId || persistence === void 0) return;
3630
+ void persistence.kanban.merge(payload).then((merged) => {
3631
+ const touched = /* @__PURE__ */ new Set();
3632
+ for (const record of payload.boards) touched.add(record.boardId);
3633
+ for (const record of payload.tombstones) touched.add(record.boardId);
3634
+ const delta = {
3635
+ projectId: merged.projectId,
3636
+ generatedAt: merged.generatedAt,
3637
+ boards: merged.boards.filter((record) => touched.has(record.boardId)),
3638
+ tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
3639
+ };
3640
+ const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
3641
+ fanoutKanbanDelta(message, clients, browsers, client.projectId);
3642
+ }).catch((error) => {
3643
+ console.warn(
3644
+ JSON.stringify({
3645
+ level: "warn",
3646
+ event: "hq.kanban_merge_failed",
3647
+ message: error instanceof Error ? error.message : String(error),
3648
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3649
+ })
3650
+ );
3651
+ });
3652
+ persistEvent(event);
3653
+ broadcastEvent(event, browsers);
3654
+ return;
3655
+ }
3656
+ if (event.type === "mailbox.snapshot") {
3657
+ const payloadResult = parseHqEventPayload(event.type, event.payload);
3658
+ if (payloadResult.ok) {
3659
+ const payload = payloadResult.payload;
3660
+ client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
3661
+ persistEvent(event);
3662
+ snapshotBroadcaster.broadcast();
3663
+ broadcastEvent(event, browsers);
3664
+ return;
3665
+ }
3666
+ return;
3667
+ }
3668
+ if (event.type === "mailbox.event") {
3669
+ const payloadResult = parseHqEventPayload(event.type, event.payload);
3670
+ if (!payloadResult.ok) {
3671
+ return;
3672
+ }
3673
+ const payload = payloadResult.payload;
3674
+ const sanitizedSummary = truncateHqSummary(payload.summary, 280);
3675
+ const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
3676
+ persistEvent(sanitizedEvent);
3677
+ broadcastEvent(sanitizedEvent, browsers);
3678
+ return;
3679
+ }
3680
+ if (event.type === "session.snapshot") {
3681
+ const result = parseHqEventPayload(event.type, event.payload);
3682
+ if (result.ok) {
3683
+ const payload = result.payload;
3684
+ client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
3685
+ snapshotBroadcaster.broadcast();
3686
+ }
3687
+ return;
3688
+ }
3689
+ if (event.type === "session.ended") {
3690
+ const result = parseHqEventPayload(event.type, event.payload);
3691
+ if (result.ok) {
3692
+ const payload = result.payload;
3693
+ client.sessions.delete(payload.sessionId);
3694
+ client.mcpSnapshots.delete(payload.sessionId);
3695
+ for (const [runId, fleet] of client.fleets) {
3696
+ if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
3697
+ }
3698
+ snapshotBroadcaster.broadcast();
3699
+ }
3700
+ return;
3701
+ }
3702
+ if (event.type === "fleet.snapshot") {
3703
+ const result = parseHqEventPayload(event.type, event.payload);
3704
+ if (result.ok) {
3705
+ const payload = result.payload;
3706
+ client.fleets.set(payload.runId, {
3707
+ payload,
3708
+ ...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
3709
+ receivedAt: Date.now()
3710
+ });
3711
+ snapshotBroadcaster.broadcast();
3712
+ }
3713
+ return;
3714
+ }
3715
+ if (event.type === "mcp.health.snapshot") {
3716
+ const result = parseHqEventPayload(event.type, event.payload);
3717
+ if (result.ok) {
3718
+ const payload = result.payload;
3719
+ const sessionId = event.sessionId || "unknown";
3720
+ const stamped = payload.servers.map((s) => ({
3721
+ ...s,
3722
+ projectId: client.projectId,
3723
+ clientId: client.clientId
3724
+ }));
3725
+ client.mcpSnapshots.set(sessionId, { servers: stamped, receivedAt: Date.now() });
3726
+ snapshotBroadcaster.broadcast();
3727
+ }
3728
+ return;
3729
+ }
3730
+ if (event.type === "governance.snapshot") {
3731
+ if (!client.isLeader) return;
3732
+ const result = parseHqEventPayload(event.type, event.payload);
3733
+ if (result.ok) {
3734
+ const payload = result.payload;
3735
+ if (payload.projectId !== client.projectId) return;
3736
+ client.governanceSnapshot = { payload, receivedAt: Date.now() };
3737
+ snapshotBroadcaster.broadcast();
3738
+ }
3739
+ return;
3740
+ }
3741
+ if (event.type === "session.transcript") {
3742
+ const result = parseHqEventPayload(event.type, event.payload);
3743
+ if (result.ok) {
3744
+ const payload = result.payload;
3745
+ let ring = transcripts.get(payload.sessionId);
3746
+ if (!ring) {
3747
+ ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
3748
+ }
3749
+ for (const entry of payload.entries) ring.entries.push(entry);
3750
+ if (ring.entries.length > TRANSCRIPT_RING_MAX) {
3751
+ ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
3752
+ }
3753
+ transcripts.delete(payload.sessionId);
3754
+ transcripts.set(payload.sessionId, ring);
3755
+ evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
3756
+ broadcastEvent(event, browsers);
3757
+ }
3758
+ return;
3759
+ }
3760
+ if (event.type === "agent.message") {
3761
+ const p = event.payload;
3762
+ const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
3763
+ if (subId) {
3764
+ const key = agentRingKey(event.sessionId, subId);
3765
+ let ring = agentMessages.get(key);
3766
+ if (!ring) ring = [];
3767
+ ring.push(agentMessageToEntry(p));
3768
+ if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
3769
+ agentMessages.delete(key);
3770
+ agentMessages.set(key, ring);
3771
+ evictOldest(agentMessages, MAX_AGENT_RINGS);
3772
+ }
3773
+ persistEvent(event);
3774
+ broadcastEvent(event, browsers);
3775
+ return;
3776
+ }
3777
+ persistEvent(event);
3778
+ broadcastEvent(event, browsers);
3779
+ }
3780
+
3781
+ // src/hq-server/ws-leader.ts
3782
+ import {
3783
+ HQ_BROWSER_PEER_RESUME_CLIENT_ID,
3784
+ HQ_PROTOCOL_VERSION
3785
+ } from "@wrongstack/core/hq";
3408
3786
  var MAX_EVENT_LOG = 5e3;
3787
+ var PEER_DEDUP_WINDOW_MS = 60 * 60 * 1e3;
3788
+ var peerDedupMap = /* @__PURE__ */ new Map();
3789
+ function peerDedupKey(projectId, previousLeaderHandle) {
3790
+ return `${projectId}::${previousLeaderHandle}`;
3791
+ }
3792
+ function checkPeerDedup(projectId, previousLeaderHandle) {
3793
+ const key = peerDedupKey(projectId, previousLeaderHandle);
3794
+ const now = Date.now();
3795
+ for (const [k, ts] of peerDedupMap) {
3796
+ if (now - ts > PEER_DEDUP_WINDOW_MS) peerDedupMap.delete(k);
3797
+ }
3798
+ const last = peerDedupMap.get(key);
3799
+ return last !== void 0 && now - last <= PEER_DEDUP_WINDOW_MS;
3800
+ }
3801
+ function recordPeerEmit(projectId, previousLeaderHandle) {
3802
+ peerDedupMap.set(peerDedupKey(projectId, previousLeaderHandle), Date.now());
3803
+ }
3804
+ var peerEventSeq = 0;
3805
+ function nextPeerEventSeq(eventLog) {
3806
+ if (eventLog !== void 0) {
3807
+ for (const env of eventLog) {
3808
+ if (env.clientId === HQ_BROWSER_PEER_RESUME_CLIENT_ID && env.seq > peerEventSeq) {
3809
+ peerEventSeq = env.seq;
3810
+ }
3811
+ }
3812
+ }
3813
+ peerEventSeq += 1;
3814
+ return peerEventSeq;
3815
+ }
3409
3816
  function computeIsLeader(clients, projectId, acceptedCapabilities) {
3410
3817
  if (!acceptedCapabilities.includes("control.receive")) return false;
3411
3818
  for (const other of clients.values()) {
@@ -3504,27 +3911,48 @@ function detectLeaderLoss(lostClient, clients, browsers, reason, eventSink) {
3504
3911
  }
3505
3912
  return true;
3506
3913
  }
3507
- var peerEventSeq = 0;
3508
- function nextPeerEventSeq(eventLog) {
3509
- if (eventLog !== void 0) {
3510
- for (const env of eventLog) {
3511
- if (env.clientId === HQ_BROWSER_PEER_RESUME_CLIENT_ID && env.seq > peerEventSeq) {
3512
- peerEventSeq = env.seq;
3914
+
3915
+ // src/hq-server/ws-resume.ts
3916
+ import {
3917
+ HQ_BROWSER_PEER_RESUME_CLIENT_ID as HQ_BROWSER_PEER_RESUME_CLIENT_ID2,
3918
+ HQ_RESUME_GAP_MAX_BYTES,
3919
+ HQ_RESUME_GAP_MAX_ENVELOPES,
3920
+ HQ_RESUME_GAP_MAX_STALE_MS
3921
+ } from "@wrongstack/core/hq";
3922
+ function browserResumeClient(ws, clientId) {
3923
+ return /* @__PURE__ */ new Map([
3924
+ [
3925
+ ws,
3926
+ {
3927
+ ws,
3928
+ clientId,
3929
+ projectId: "",
3930
+ project: {
3931
+ projectId: "",
3932
+ projectName: "",
3933
+ projectRoot: "",
3934
+ machineId: "",
3935
+ workspaceKind: "git"
3936
+ },
3937
+ kind: "browser",
3938
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
3939
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
3940
+ capabilities: [],
3941
+ declaredRedactionPolicy: {
3942
+ rawContent: false,
3943
+ toolArgs: "summary",
3944
+ paths: "project-relative"
3945
+ },
3946
+ lastEventSeq: 0,
3947
+ mailboxes: /* @__PURE__ */ new Map(),
3948
+ sessions: /* @__PURE__ */ new Map(),
3949
+ fleets: /* @__PURE__ */ new Map(),
3950
+ mcpSnapshots: /* @__PURE__ */ new Map(),
3951
+ commandQueue: [],
3952
+ isLeader: false
3513
3953
  }
3514
- }
3515
- }
3516
- peerEventSeq += 1;
3517
- return peerEventSeq;
3518
- }
3519
- function fanoutKanbanDelta(message, clients, _browsers, projectId) {
3520
- let clientsNotified = 0;
3521
- for (const peer of clients.values()) {
3522
- if (peer.projectId === projectId) {
3523
- sendGuarded(peer.ws, message);
3524
- clientsNotified++;
3525
- }
3526
- }
3527
- return { clientsNotified, browsersNotified: 0 };
3954
+ ]
3955
+ ]);
3528
3956
  }
3529
3957
  function handleClientResume(ws, clients, eventLog, frame, snapshotBroadcaster) {
3530
3958
  const client = clients.get(ws);
@@ -3542,7 +3970,7 @@ function handleClientResume(ws, clients, eventLog, frame, snapshotBroadcaster) {
3542
3970
  const now = Date.now();
3543
3971
  const recent = [];
3544
3972
  let cursorTimestamp;
3545
- const skipPeerEnvelopes = client.clientId !== HQ_BROWSER_PEER_RESUME_CLIENT_ID;
3973
+ const skipPeerEnvelopes = client.clientId !== HQ_BROWSER_PEER_RESUME_CLIENT_ID2;
3546
3974
  for (const env of eventLog) {
3547
3975
  if (env.clientId !== client.clientId) continue;
3548
3976
  if (skipPeerEnvelopes && (env.type === "peer.rehydrate" || env.type === "peer.lost")) {
@@ -3614,60 +4042,10 @@ function handleClientResume(ws, clients, eventLog, frame, snapshotBroadcaster) {
3614
4042
  truncated: false
3615
4043
  })
3616
4044
  );
3617
- return;
3618
- }
3619
- var PEER_DEDUP_WINDOW_MS = 60 * 60 * 1e3;
3620
- var peerDedupMap = /* @__PURE__ */ new Map();
3621
- function peerDedupKey(projectId, previousLeaderHandle) {
3622
- return `${projectId}::${previousLeaderHandle}`;
3623
- }
3624
- function checkPeerDedup(projectId, previousLeaderHandle) {
3625
- const key = peerDedupKey(projectId, previousLeaderHandle);
3626
- const now = Date.now();
3627
- for (const [k, ts] of peerDedupMap) {
3628
- if (now - ts > PEER_DEDUP_WINDOW_MS) peerDedupMap.delete(k);
3629
- }
3630
- const last = peerDedupMap.get(key);
3631
- return last !== void 0 && now - last <= PEER_DEDUP_WINDOW_MS;
3632
- }
3633
- function recordPeerEmit(projectId, previousLeaderHandle) {
3634
- peerDedupMap.set(peerDedupKey(projectId, previousLeaderHandle), Date.now());
3635
- }
3636
- function browserResumeClient(ws, clientId) {
3637
- return /* @__PURE__ */ new Map([
3638
- [
3639
- ws,
3640
- {
3641
- ws,
3642
- clientId,
3643
- projectId: "",
3644
- project: {
3645
- projectId: "",
3646
- projectName: "",
3647
- projectRoot: "",
3648
- machineId: "",
3649
- workspaceKind: "git"
3650
- },
3651
- kind: "browser",
3652
- connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
3653
- lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
3654
- capabilities: [],
3655
- declaredRedactionPolicy: {
3656
- rawContent: false,
3657
- toolArgs: "summary",
3658
- paths: "project-relative"
3659
- },
3660
- lastEventSeq: 0,
3661
- mailboxes: /* @__PURE__ */ new Map(),
3662
- sessions: /* @__PURE__ */ new Map(),
3663
- fleets: /* @__PURE__ */ new Map(),
3664
- mcpSnapshots: /* @__PURE__ */ new Map(),
3665
- commandQueue: [],
3666
- isLeader: false
3667
- }
3668
- ]
3669
- ]);
3670
4045
  }
4046
+
4047
+ // src/hq-server/ws.ts
4048
+ var MAX_EVENT_LOG2 = 5e3;
3671
4049
  function handleBrowser(ws, snapshotBroadcaster, browsers, eventLog) {
3672
4050
  browsers.add(ws);
3673
4051
  ws.on("error", (err) => {
@@ -3723,7 +4101,7 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
3723
4101
  let registered = false;
3724
4102
  function persistEvent(event) {
3725
4103
  eventLog.push(event);
3726
- if (eventLog.length > MAX_EVENT_LOG) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
4104
+ if (eventLog.length > MAX_EVENT_LOG2) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG2);
3727
4105
  if (persistence !== void 0) {
3728
4106
  persistence.eventLog.append(event);
3729
4107
  recordTimeseriesSignal(persistence, event);
@@ -3754,11 +4132,11 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
3754
4132
  return;
3755
4133
  }
3756
4134
  const payload = frame.payload;
3757
- if (payload.protocolVersion !== HQ_PROTOCOL_VERSION) {
4135
+ if (payload.protocolVersion !== HQ_PROTOCOL_VERSION2) {
3758
4136
  ws.close(1008, "protocol version mismatch");
3759
4137
  return;
3760
4138
  }
3761
- const canPublishTelemetry = auth.token === void 0 || tokenHasCapability2(auth.token, "telemetry.publish");
4139
+ const canPublishTelemetry = auth.token === void 0 || tokenHasCapability3(auth.token, "telemetry.publish");
3762
4140
  const acceptedCapabilities = payload.capabilities.filter(
3763
4141
  (capability) => capability === "control.receive" || canPublishTelemetry
3764
4142
  );
@@ -3798,11 +4176,6 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
3798
4176
  fleets: /* @__PURE__ */ new Map(),
3799
4177
  mcpSnapshots: /* @__PURE__ */ new Map(),
3800
4178
  commandQueue: [],
3801
- // Leader for the project is the unique client with `control.receive`
3802
- // capability. First such client wins; subsequent clients with the
3803
- // capability are followers. Same-clientId reconnects inherit this
3804
- // flag from the superseded socket because that identity never really
3805
- // disappeared; different-client supersedes still use the loss detector.
3806
4179
  isLeader: inheritedLeader || computeIsLeader(clients, payload.project.projectId, acceptedCapabilities)
3807
4180
  };
3808
4181
  clients.set(ws, client);
@@ -3812,26 +4185,24 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
3812
4185
  }
3813
4186
  const welcome = {
3814
4187
  type: "hq.welcome",
3815
- protocolVersion: HQ_PROTOCOL_VERSION,
4188
+ protocolVersion: HQ_PROTOCOL_VERSION2,
3816
4189
  serverTime: (/* @__PURE__ */ new Date()).toISOString(),
3817
4190
  acceptedCapabilities,
3818
- // The operator-configured override (from <dataDir>/auth.json) wins
3819
- // over the default. The client learns the *effective* policy.
3820
- redactionPolicy: tightenHqRedactionPolicy(
4191
+ redactionPolicy: tightenHqRedactionPolicy2(
3821
4192
  declaredRedactionPolicy,
3822
4193
  auth.getOperatorPolicy()
3823
4194
  )
3824
4195
  };
3825
4196
  ws.send(JSON.stringify(welcome));
3826
4197
  if (persistence !== void 0) {
3827
- void persistence.kanban.load(client.projectId).then((payload2) => {
3828
- sendGuarded(ws, JSON.stringify({ type: "hq.kanban_snapshot", payload: payload2 }));
4198
+ void persistence.kanban.load(client.projectId).then((p) => {
4199
+ sendGuarded(ws, JSON.stringify({ type: "hq.kanban_snapshot", payload: p }));
3829
4200
  }).catch(() => void 0);
3830
4201
  }
3831
4202
  const event = {
3832
- id: randomUUID3(),
4203
+ id: randomUUID5(),
3833
4204
  type: "client.hello",
3834
- schemaVersion: HQ_PROTOCOL_VERSION,
4205
+ schemaVersion: HQ_PROTOCOL_VERSION2,
3835
4206
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3836
4207
  clientId: payload.client.clientId,
3837
4208
  projectId: payload.project.projectId,
@@ -3904,223 +4275,148 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
3904
4275
  return;
3905
4276
  }
3906
4277
  if (frame.type === "client.event") {
3907
- const client = clients.get(ws);
3908
- if (!client) return;
3909
- const incomingEvent = frame.event;
3910
- if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
3911
- ws.close(1008, "event identity mismatch");
3912
- return;
3913
- }
3914
- if (!tokenHasCapability2(client.authToken, "telemetry.publish") && auth.token !== void 0) {
3915
- ws.close(1008, "client token lacks telemetry.publish capability");
3916
- return;
3917
- }
3918
- if (incomingEvent.seq <= client.lastEventSeq) {
3919
- ws.close(1008, "event sequence must increase monotonically");
3920
- return;
4278
+ handleIncomingClientEvent(
4279
+ ws,
4280
+ frame.event,
4281
+ clients,
4282
+ browsers,
4283
+ auth,
4284
+ snapshotBroadcaster,
4285
+ transcripts,
4286
+ agentMessages,
4287
+ persistence,
4288
+ persistEvent
4289
+ );
4290
+ }
4291
+ });
4292
+ ws.on("close", () => {
4293
+ const lostClient = clients.get(ws);
4294
+ clients.delete(ws);
4295
+ if (lostClient) {
4296
+ detectLeaderLoss(lostClient, clients, browsers, "graceful", { eventLog, persistence });
4297
+ }
4298
+ snapshotBroadcaster.broadcast();
4299
+ });
4300
+ }
4301
+
4302
+ // src/hq-server/upgrade-handler.ts
4303
+ function handleHqUpgrade(req, socket, head, deps) {
4304
+ const url = new URL(req.url ?? "/", `http://${deps.host}:${deps.port}`);
4305
+ const pathname = url.pathname;
4306
+ if (pathname !== "/ws/client" && pathname !== "/ws/browser") {
4307
+ socket.destroy();
4308
+ return;
4309
+ }
4310
+ if (!hasTrustedBrowserOrigin2(
4311
+ req,
4312
+ deps.host,
4313
+ deps.listeningPort(),
4314
+ deps.trustedPublicOrigins,
4315
+ deps.allowFileOrigin
4316
+ )) {
4317
+ socket.write(
4318
+ "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
4319
+ error: {
4320
+ code: "INVALID_ORIGIN",
4321
+ message: "Cross-origin WebSocket upgrade rejected."
4322
+ }
4323
+ })
4324
+ );
4325
+ socket.destroy();
4326
+ return;
4327
+ }
4328
+ const tokenSet = pathname === "/ws/browser" ? deps.mutableAuth.browserTokens : deps.mutableAuth.clientTokens;
4329
+ const needsAuth = pathname === "/ws/browser" ? hqAuthRequired(deps.mutableAuth, deps.requireBrowserAuth) : hqClientAuthRequired(deps.mutableAuth);
4330
+ if (needsAuth) {
4331
+ let supplied = url.searchParams.get("token") ?? "";
4332
+ if (supplied && pathname === "/ws/browser") {
4333
+ const requestHost = (req.headers.host ?? "").trim();
4334
+ let wsHostname = "";
4335
+ try {
4336
+ wsHostname = new URL(`http://${requestHost}`).hostname;
4337
+ } catch {
3921
4338
  }
3922
- const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
3923
- if (!parsedPayload.ok) {
4339
+ if (wsHostname && !isLoopbackHost3(wsHostname)) {
3924
4340
  console.warn(
3925
4341
  JSON.stringify({
3926
4342
  level: "warn",
3927
- event: "hq.event_payload_rejected",
3928
- eventType: incomingEvent.type,
3929
- clientId: client.clientId,
3930
- projectId: client.projectId,
4343
+ event: "hq.ws_token_from_query_rejected",
4344
+ message: "Browser token in WS query rejected on non-loopback \u2014 use session cookie instead.",
3931
4345
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3932
4346
  })
3933
4347
  );
3934
- return;
3935
- }
3936
- client.lastEventSeq = incomingEvent.seq;
3937
- client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
3938
- const event = redactHqEvent(
3939
- { ...incomingEvent, payload: parsedPayload.payload },
3940
- {
3941
- policy: tightenHqRedactionPolicy(
3942
- client.declaredRedactionPolicy,
3943
- auth.getOperatorPolicy()
3944
- ),
3945
- projectRoot: client.project.projectRoot,
3946
- // Chat-transcript events carry full turns — the generic 500-char
3947
- // summary cap would truncate them a second time after the
3948
- // publisher already applied the transcript cap.
3949
- ...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
3950
- }
3951
- ).value;
3952
- if (event.type === "client.heartbeat") {
3953
- return;
3954
- }
3955
- if (event.type === "kanban.snapshot") {
3956
- const payload = event.payload;
3957
- if (payload.projectId !== client.projectId || persistence === void 0) return;
3958
- void persistence.kanban.merge(payload).then((merged) => {
3959
- const touched = /* @__PURE__ */ new Set();
3960
- for (const record of payload.boards) touched.add(record.boardId);
3961
- for (const record of payload.tombstones) touched.add(record.boardId);
3962
- const delta = {
3963
- projectId: merged.projectId,
3964
- generatedAt: merged.generatedAt,
3965
- boards: merged.boards.filter((record) => touched.has(record.boardId)),
3966
- tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
3967
- };
3968
- const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
3969
- fanoutKanbanDelta(message, clients, browsers, client.projectId);
3970
- }).catch((error) => {
3971
- console.warn(
3972
- JSON.stringify({
3973
- level: "warn",
3974
- event: "hq.kanban_merge_failed",
3975
- message: error instanceof Error ? error.message : String(error),
3976
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3977
- })
3978
- );
3979
- });
3980
- persistEvent(event);
3981
- broadcastEvent(event, browsers);
3982
- return;
3983
- }
3984
- if (event.type === "mailbox.snapshot" && client !== void 0) {
3985
- const payloadResult = parseHqEventPayload(event.type, event.payload);
3986
- if (payloadResult.ok) {
3987
- const payload = payloadResult.payload;
3988
- client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
3989
- persistEvent(event);
3990
- snapshotBroadcaster.broadcast();
3991
- broadcastEvent(event, browsers);
3992
- return;
3993
- }
3994
- return;
3995
- }
3996
- if (event.type === "mailbox.event") {
3997
- const payloadResult = parseHqEventPayload(event.type, event.payload);
3998
- if (!payloadResult.ok) {
3999
- return;
4000
- }
4001
- const payload = payloadResult.payload;
4002
- const sanitizedSummary = truncateHqSummary(payload.summary, 280);
4003
- const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
4004
- persistEvent(sanitizedEvent);
4005
- broadcastEvent(sanitizedEvent, browsers);
4006
- return;
4007
- }
4008
- if (event.type === "session.snapshot" && client !== void 0) {
4009
- const result = parseHqEventPayload(event.type, event.payload);
4010
- if (result.ok) {
4011
- const payload = result.payload;
4012
- client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
4013
- snapshotBroadcaster.broadcast();
4014
- }
4015
- return;
4016
- }
4017
- if (event.type === "session.ended" && client !== void 0) {
4018
- const result = parseHqEventPayload(event.type, event.payload);
4019
- if (result.ok) {
4020
- const payload = result.payload;
4021
- client.sessions.delete(payload.sessionId);
4022
- client.mcpSnapshots.delete(payload.sessionId);
4023
- for (const [runId, fleet] of client.fleets) {
4024
- if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
4025
- }
4026
- snapshotBroadcaster.broadcast();
4027
- }
4028
- return;
4029
- }
4030
- if (event.type === "fleet.snapshot" && client !== void 0) {
4031
- const result = parseHqEventPayload(event.type, event.payload);
4032
- if (result.ok) {
4033
- const payload = result.payload;
4034
- client.fleets.set(payload.runId, {
4035
- payload,
4036
- ...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
4037
- receivedAt: Date.now()
4038
- });
4039
- snapshotBroadcaster.broadcast();
4040
- }
4041
- return;
4042
- }
4043
- if (event.type === "mcp.health.snapshot" && client !== void 0) {
4044
- const result = parseHqEventPayload(event.type, event.payload);
4045
- if (result.ok) {
4046
- const payload = result.payload;
4047
- const sessionId = event.sessionId || "unknown";
4048
- const stamped = payload.servers.map((s) => ({
4049
- ...s,
4050
- projectId: client.projectId,
4051
- clientId: client.clientId
4052
- }));
4053
- client.mcpSnapshots.set(sessionId, { servers: stamped, receivedAt: Date.now() });
4054
- snapshotBroadcaster.broadcast();
4055
- }
4056
- return;
4348
+ supplied = "";
4057
4349
  }
4058
- if (event.type === "governance.snapshot" && client !== void 0) {
4059
- if (!client.isLeader) return;
4060
- const result = parseHqEventPayload(event.type, event.payload);
4061
- if (result.ok) {
4062
- const payload = result.payload;
4063
- if (payload.projectId !== client.projectId) return;
4064
- client.governanceSnapshot = { payload, receivedAt: Date.now() };
4065
- snapshotBroadcaster.broadcast();
4066
- }
4067
- return;
4350
+ }
4351
+ const tokenValid = timingSafeTokenMatch(tokenSet, supplied) !== void 0;
4352
+ const cookieValid = pathname === "/ws/browser" && deps.mutableAuth.cookieSecret !== void 0 && (() => {
4353
+ const raw = readHqSessionCookie(req.headers.cookie);
4354
+ if (!raw) return false;
4355
+ const sessionId = parseHqSessionCookie2(raw, deps.mutableAuth.cookieSecret);
4356
+ if (sessionId === void 0) return false;
4357
+ const session = deps.sessions.get(sessionId);
4358
+ if (!session || Date.now() - session.createdAt >= HQ_SESSION_MAX_AGE_MS)
4359
+ return false;
4360
+ const now2 = Date.now();
4361
+ if (!session.pending2fa && now2 - session.lastSeenAt > HQ_SESSION_IDLE_TIMEOUT_MS)
4362
+ return false;
4363
+ session.lastSeenAt = now2;
4364
+ if (session.pending2fa) return false;
4365
+ if (session.kind === "token" && session.tokenId !== void 0) {
4366
+ return [...deps.mutableAuth.browserTokenObjs.values()].some(
4367
+ (obj) => obj.id === session.tokenId
4368
+ );
4068
4369
  }
4069
- if (event.type === "session.transcript" && client !== void 0) {
4070
- const result = parseHqEventPayload(event.type, event.payload);
4071
- if (result.ok) {
4072
- const payload = result.payload;
4073
- let ring = transcripts.get(payload.sessionId);
4074
- if (!ring) {
4075
- ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
4076
- }
4077
- for (const entry of payload.entries) ring.entries.push(entry);
4078
- if (ring.entries.length > TRANSCRIPT_RING_MAX) {
4079
- ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
4370
+ return true;
4371
+ })();
4372
+ if (!tokenValid && !cookieValid) {
4373
+ socket.write(
4374
+ "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
4375
+ error: {
4376
+ code: "UNAUTHORIZED",
4377
+ message: pathname === "/ws/browser" ? "A valid ?token= or password session is required for browser connections." : "A valid ?token= is required for client connections in token mode."
4080
4378
  }
4081
- transcripts.delete(payload.sessionId);
4082
- transcripts.set(payload.sessionId, ring);
4083
- evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
4084
- broadcastEvent(event, browsers);
4085
- }
4086
- return;
4087
- }
4088
- if (event.type === "agent.message") {
4089
- const p = event.payload;
4090
- const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
4091
- if (subId) {
4092
- const key = agentRingKey(event.sessionId, subId);
4093
- let ring = agentMessages.get(key);
4094
- if (!ring) ring = [];
4095
- ring.push(agentMessageToEntry(p));
4096
- if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
4097
- agentMessages.delete(key);
4098
- agentMessages.set(key, ring);
4099
- evictOldest(agentMessages, MAX_AGENT_RINGS);
4100
- }
4101
- persistEvent(event);
4102
- broadcastEvent(event, browsers);
4103
- return;
4104
- }
4105
- persistEvent(event);
4106
- broadcastEvent(event, browsers);
4107
- }
4108
- });
4109
- ws.on("close", () => {
4110
- const lostClient = clients.get(ws);
4111
- clients.delete(ws);
4112
- if (lostClient) {
4113
- detectLeaderLoss(lostClient, clients, browsers, "graceful", { eventLog, persistence });
4379
+ })
4380
+ );
4381
+ socket.destroy();
4382
+ return;
4114
4383
  }
4115
- snapshotBroadcaster.broadcast();
4384
+ }
4385
+ deps.wss.handleUpgrade(req, socket, head, (ws) => {
4386
+ deps.wss.emit("connection", ws, req, pathname);
4116
4387
  });
4117
4388
  }
4389
+ function handleHqConnection(ws, req, pathname, deps) {
4390
+ if (pathname === "/ws/browser") {
4391
+ handleBrowser(ws, deps.snapshotBroadcaster, deps.browsers, deps.eventLog);
4392
+ } else {
4393
+ const token = new URL(req.url ?? "/", `http://${deps.host}:${deps.port}`).searchParams.get("token");
4394
+ const authToken = token ? deps.mutableAuth.clientTokenObjs.get(hqTokenVerifier2(token)) : void 0;
4395
+ deps.clientSocketTokens.set(ws, authToken);
4396
+ ws.once("close", () => deps.clientSocketTokens.delete(ws));
4397
+ handleClient(
4398
+ ws,
4399
+ deps.clients,
4400
+ deps.browsers,
4401
+ deps.eventLog,
4402
+ {
4403
+ ...authToken ? { token: authToken } : {},
4404
+ getOperatorPolicy: () => deps.mutableAuth.operatorPolicyOverride
4405
+ },
4406
+ deps.snapshotBroadcaster,
4407
+ deps.transcripts,
4408
+ deps.agentMessages,
4409
+ deps.persistence,
4410
+ deps.auditLog
4411
+ );
4412
+ }
4413
+ }
4118
4414
 
4119
4415
  // src/hq-server.ts
4120
4416
  import { HqInsecureExposureError as HqInsecureExposureError2 } from "@wrongstack/core/hq";
4121
4417
  var DEFAULT_HOST = "127.0.0.1";
4122
4418
  var DEFAULT_PORT = 3499;
4123
- var MAX_EVENT_LOG2 = 5e3;
4419
+ var MAX_EVENT_LOG3 = 5e3;
4124
4420
  var MAX_NON_STRICT_PORT_SCAN = 50;
4125
4421
  var CLIENT_TTL_MS = 6e4;
4126
4422
  var CLIENT_CLEANUP_INTERVAL_MS = 3e4;
@@ -4163,7 +4459,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4163
4459
  const { mutableAuth } = authState;
4164
4460
  if (!mutableAuth.cookieSecret && mutableAuth.browserTokens.size > 0) {
4165
4461
  const secret = mintHqCookieSecret2();
4166
- const next = await mutateHqAuthFile2(dataDir, (current) => ({
4462
+ const next = await mutateHqAuthFile3(dataDir, (current) => ({
4167
4463
  ...current,
4168
4464
  cookieSecret: secret
4169
4465
  }));
@@ -4203,16 +4499,12 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4203
4499
  const eventLog = [];
4204
4500
  const transcripts = /* @__PURE__ */ new Map();
4205
4501
  const agentMessages = /* @__PURE__ */ new Map();
4206
- const mailboxGateways = /* @__PURE__ */ new Map();
4207
- const MAILBOX_GATEWAY_IDLE_TTL_MS = 15 * 6e4;
4208
- const MAILBOX_GATEWAY_SWEEP_INTERVAL_MS = 6e4;
4209
- const mailboxGatewayLastUsed = /* @__PURE__ */ new Map();
4210
- const mailboxGatewayRateLimiter = new MailboxHttpRateLimiter();
4211
- const mailboxGatewayRateLimitCleanup = setInterval(
4212
- () => mailboxGatewayRateLimiter.cleanup(),
4213
- 12e4
4214
- );
4215
- mailboxGatewayRateLimitCleanup.unref?.();
4502
+ const mailboxManager = new MailboxGatewayManager({
4503
+ host,
4504
+ port,
4505
+ mutableAuth,
4506
+ sessions
4507
+ });
4216
4508
  const loginAttempts = new LoginAttemptStore(dataDir);
4217
4509
  void loginAttempts.load();
4218
4510
  const HQ_SESSION_MAX_AGE_MS2 = 7 * 24 * 60 * 60 * 1e3;
@@ -4276,76 +4568,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4276
4568
  );
4277
4569
  })().catch(() => {
4278
4570
  });
4279
- const hqSessionTag = randomUUID4().slice(0, 8);
4280
- const authorizeMailboxGateway = (req, projectDir) => {
4281
- const requestUrl = new URL(req.url ?? "/", `http://${host}:${port}`);
4282
- const auth = authenticateBrowserRequest2(req, requestUrl, mutableAuth, sessions);
4283
- const tokenMode = mutableAuth.browserTokens.size > 0;
4284
- if (hqAuthRequired(mutableAuth) && auth === void 0) {
4285
- return {
4286
- allowed: false,
4287
- status: 401,
4288
- body: { error: { code: "UNAUTHORIZED", message: "unauthorized" } }
4289
- };
4290
- }
4291
- const token = isTokenAuth2(auth) ? mutableAuth.browserTokenObjs.get(auth.token) : void 0;
4292
- const canUseMailbox = isCookieAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? token?.capabilities === void 0 || !!token?.capabilities.includes("control.enqueue") : !tokenMode;
4293
- if (!canUseMailbox) {
4294
- return {
4295
- allowed: false,
4296
- status: 403,
4297
- body: {
4298
- error: {
4299
- code: "FORBIDDEN",
4300
- message: "forbidden: token lacks control.enqueue capability"
4301
- }
4302
- }
4303
- };
4304
- }
4305
- const identity = isTokenAuth2(auth) ? auth.id : isCookieAuth2(auth) ? "cookie" : "open";
4306
- return { allowed: true, rateLimitKey: `hq:${identity}:${projectDir}` };
4307
- };
4308
- const getMailboxGateway = (projectDir) => {
4309
- const existing = mailboxGateways.get(projectDir);
4310
- if (existing) {
4311
- mailboxGatewayLastUsed.set(projectDir, Date.now());
4312
- return existing;
4313
- }
4314
- const eventEmitter = new MailboxEventEmitter();
4315
- const mailbox = createProjectMailbox({ projectDir, eventEmitter });
4316
- const router = createMailboxHttpRouter({
4317
- mailbox,
4318
- eventEmitter,
4319
- rateLimiter: mailboxGatewayRateLimiter,
4320
- authorize: (request) => authorizeMailboxGateway(request, projectDir),
4321
- // Wire the 1h look-back that the router's docs promise at
4322
- // mailbox-http-router.ts:25-32 / :47-62. The router itself is
4323
- // opt-in (L146-152): without this option, every retained
4324
- // message would be returned. Per-request opt-in to the full
4325
- // history remains available via `?sinceMs=0`.
4326
- defaultMaxAgeMs: MAILBOX_HTTP_DEFAULT_MAX_AGE_MS
4327
- });
4328
- const gateway = { mailbox, router };
4329
- mailboxGateways.set(projectDir, gateway);
4330
- mailboxGatewayLastUsed.set(projectDir, Date.now());
4331
- return gateway;
4332
- };
4333
- const evictIdleMailboxGateways = () => {
4334
- const cutoff = Date.now() - MAILBOX_GATEWAY_IDLE_TTL_MS;
4335
- for (const [projectDir, gateway] of [...mailboxGateways]) {
4336
- if ((mailboxGatewayLastUsed.get(projectDir) ?? 0) > cutoff) continue;
4337
- if (gateway.router.hasActiveStreams()) continue;
4338
- mailboxGateways.delete(projectDir);
4339
- mailboxGatewayLastUsed.delete(projectDir);
4340
- gateway.router.close();
4341
- void gateway.mailbox.close().catch(() => void 0);
4342
- }
4343
- };
4344
- const mailboxGatewaySweepTimer = setInterval(
4345
- evictIdleMailboxGateways,
4346
- MAILBOX_GATEWAY_SWEEP_INTERVAL_MS
4347
- );
4348
- mailboxGatewaySweepTimer.unref?.();
4571
+ const hqSessionTag = randomUUID6().slice(0, 8);
4349
4572
  const alertEngine = new HqAlertEngine({
4350
4573
  onAlert: (alert) => {
4351
4574
  const msg = toAlertMessage(alert);
@@ -4358,7 +4581,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4358
4581
  });
4359
4582
  void persistence.eventLog.hydrate().catch(() => void 0);
4360
4583
  void persistence.timeseries.load().catch(() => void 0);
4361
- persistence.eventLog.recent(MAX_EVENT_LOG2).then((prior) => {
4584
+ persistence.eventLog.recent(MAX_EVENT_LOG3).then((prior) => {
4362
4585
  for (let i = prior.length - 1; i >= 0; i--) eventLog.push(prior[i]);
4363
4586
  }).catch(() => {
4364
4587
  });
@@ -4445,8 +4668,8 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4445
4668
  eventLog,
4446
4669
  transcripts,
4447
4670
  agentMessages,
4448
- mailboxGateways,
4449
- mailboxGatewayRateLimiter,
4671
+ mailboxGateways: mailboxManager.mailboxGateways,
4672
+ mailboxGatewayRateLimiter: mailboxManager.mailboxGatewayRateLimiter,
4450
4673
  alertEngine,
4451
4674
  auditLog,
4452
4675
  persistence,
@@ -4454,11 +4677,9 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4454
4677
  hqSessionTag,
4455
4678
  requireBrowserAuth: options.requireBrowserAuth,
4456
4679
  secureCookies: options.secureCookies,
4457
- authorizeMailboxGateway,
4458
- getMailboxGateway,
4680
+ authorizeMailboxGateway: (req, projectDir) => mailboxManager.authorizeMailboxGateway(req, projectDir),
4681
+ getMailboxGateway: (projectDir) => mailboxManager.getMailboxGateway(projectDir),
4459
4682
  getTokenStats: () => authState.tokenStats(),
4460
- // Single projection + single floor evaluation, shared with the reload
4461
- // watcher below. See `createHqAuthState`'s `onApplied` hook.
4462
4683
  applyAuthFile: (next) => authState.apply(next),
4463
4684
  trustedProxyHops: options.trustedProxyHops ?? 0,
4464
4685
  bootstrapStore
@@ -4478,120 +4699,31 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4478
4699
  }
4479
4700
  }, options.browserHeartbeatIntervalMs ?? BROWSER_HEARTBEAT_INTERVAL_MS);
4480
4701
  browserHeartbeatTimer.unref?.();
4702
+ const upgradeDeps = {
4703
+ host,
4704
+ port,
4705
+ listeningPort: () => listeningPort,
4706
+ trustedPublicOrigins,
4707
+ allowFileOrigin: options.allowFileOrigin,
4708
+ requireBrowserAuth: options.requireBrowserAuth,
4709
+ mutableAuth,
4710
+ sessions,
4711
+ clients,
4712
+ clientSocketTokens,
4713
+ browsers,
4714
+ eventLog,
4715
+ transcripts,
4716
+ agentMessages,
4717
+ persistence,
4718
+ auditLog,
4719
+ snapshotBroadcaster,
4720
+ wss
4721
+ };
4481
4722
  httpServer.on("upgrade", (req, socket, head) => {
4482
- const url = new URL(req.url ?? "/", `http://${host}:${port}`);
4483
- const pathname = url.pathname;
4484
- if (pathname !== "/ws/client" && pathname !== "/ws/browser") {
4485
- socket.destroy();
4486
- return;
4487
- }
4488
- if (!hasTrustedBrowserOrigin2(
4489
- req,
4490
- host,
4491
- listeningPort,
4492
- trustedPublicOrigins,
4493
- options.allowFileOrigin
4494
- )) {
4495
- socket.write(
4496
- "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
4497
- error: {
4498
- code: "INVALID_ORIGIN",
4499
- message: "Cross-origin WebSocket upgrade rejected."
4500
- }
4501
- })
4502
- );
4503
- socket.destroy();
4504
- return;
4505
- }
4506
- const tokenSet = pathname === "/ws/browser" ? mutableAuth.browserTokens : mutableAuth.clientTokens;
4507
- const needsAuth = pathname === "/ws/browser" ? hqAuthRequired(mutableAuth, options.requireBrowserAuth) : hqClientAuthRequired(mutableAuth);
4508
- if (needsAuth) {
4509
- let supplied = url.searchParams.get("token") ?? "";
4510
- if (supplied && pathname === "/ws/browser") {
4511
- const requestHost = (req.headers.host ?? "").trim();
4512
- let wsHostname = "";
4513
- try {
4514
- wsHostname = new URL(`http://${requestHost}`).hostname;
4515
- } catch {
4516
- }
4517
- if (wsHostname && !isLoopbackHost3(wsHostname)) {
4518
- console.warn(
4519
- JSON.stringify({
4520
- level: "warn",
4521
- event: "hq.ws_token_from_query_rejected",
4522
- message: "Browser token in WS query rejected on non-loopback \u2014 use session cookie instead.",
4523
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4524
- })
4525
- );
4526
- supplied = "";
4527
- }
4528
- }
4529
- const tokenValid = timingSafeTokenMatch(tokenSet, supplied) !== void 0;
4530
- const cookieValid = pathname === "/ws/browser" && mutableAuth.cookieSecret !== void 0 && (() => {
4531
- const raw = readHqSessionCookie(req.headers.cookie);
4532
- if (!raw) return false;
4533
- const sessionId = parseHqSessionCookie2(raw, mutableAuth.cookieSecret);
4534
- if (sessionId === void 0) return false;
4535
- const session = sessions.get(sessionId);
4536
- if (!session || Date.now() - session.createdAt >= HQ_SESSION_MAX_AGE_MS)
4537
- return false;
4538
- const now2 = Date.now();
4539
- if (!session.pending2fa && now2 - session.lastSeenAt > HQ_SESSION_IDLE_TIMEOUT_MS)
4540
- return false;
4541
- session.lastSeenAt = now2;
4542
- if (session.pending2fa) return false;
4543
- if (session.kind === "token" && session.tokenId !== void 0) {
4544
- return [...mutableAuth.browserTokenObjs.values()].some(
4545
- (obj) => obj.id === session.tokenId
4546
- );
4547
- }
4548
- return true;
4549
- })();
4550
- if (!tokenValid && !cookieValid) {
4551
- socket.write(
4552
- "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
4553
- error: {
4554
- code: "UNAUTHORIZED",
4555
- message: pathname === "/ws/browser" ? "A valid ?token= or password session is required for browser connections." : "A valid ?token= is required for client connections in token mode."
4556
- }
4557
- })
4558
- );
4559
- socket.destroy();
4560
- return;
4561
- }
4562
- }
4563
- wss.handleUpgrade(req, socket, head, (ws) => {
4564
- wss.emit("connection", ws, req, pathname);
4565
- });
4723
+ handleHqUpgrade(req, socket, head, upgradeDeps);
4566
4724
  });
4567
4725
  wss.on("connection", (ws, req, pathname) => {
4568
- if (pathname === "/ws/browser") {
4569
- handleBrowser(ws, snapshotBroadcaster, browsers, eventLog);
4570
- } else {
4571
- const token = new URL(req.url ?? "/", `http://${host}:${port}`).searchParams.get("token");
4572
- const authToken = token ? mutableAuth.clientTokenObjs.get(hqTokenVerifier2(token)) : void 0;
4573
- clientSocketTokens.set(ws, authToken);
4574
- ws.once("close", () => clientSocketTokens.delete(ws));
4575
- handleClient(
4576
- ws,
4577
- clients,
4578
- browsers,
4579
- eventLog,
4580
- {
4581
- // WS-044: `clientTokenObjs` is keyed on `hqTokenKey` — the verifier —
4582
- // so the presented secret is hashed before lookup. Indexing with the
4583
- // raw value silently returned undefined, which downgraded a scoped
4584
- // token to "no capabilities record" at the capability gate.
4585
- ...authToken ? { token: authToken } : {},
4586
- getOperatorPolicy: () => mutableAuth.operatorPolicyOverride
4587
- },
4588
- snapshotBroadcaster,
4589
- transcripts,
4590
- agentMessages,
4591
- persistence,
4592
- auditLog
4593
- );
4594
- }
4726
+ handleHqConnection(ws, req, pathname, upgradeDeps);
4595
4727
  });
4596
4728
  const authWatcher = watchHqAuthFile(
4597
4729
  dataDir,
@@ -4697,7 +4829,6 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4697
4829
  browserTokenMode: mutableAuth.browserTokens.size > 0,
4698
4830
  passwordMode: mutableAuth.passwordHash !== void 0
4699
4831
  };
4700
- let closed = false;
4701
4832
  const handle = {
4702
4833
  host,
4703
4834
  port: actualPort,
@@ -4711,47 +4842,24 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
4711
4842
  }
4712
4843
  trustedPublicOrigins.add(parsed.origin.toLowerCase());
4713
4844
  },
4714
- close: () => new Promise((res) => {
4715
- if (closed) {
4716
- res();
4717
- return;
4718
- }
4719
- closed = true;
4720
- clearInterval(cleanupTimer);
4721
- clearInterval(browserHeartbeatTimer);
4722
- clearInterval(timeseriesFlushTimer);
4723
- clearInterval(mailboxGatewayRateLimitCleanup);
4724
- clearInterval(mailboxGatewaySweepTimer);
4725
- clearInterval(sessionCleanupTimer);
4726
- void loginAttempts.flush();
4727
- stopAlertEngine();
4728
- authWatcher.close();
4729
- snapshotBroadcaster.close();
4730
- bootstrapStore.clear();
4731
- persistence.timeseries.flush();
4732
- for (const ws of [...browsers, ...clients.keys()]) {
4733
- try {
4734
- ws.close(1001, "HQ shutting down");
4735
- ws.terminate();
4736
- } catch {
4737
- }
4738
- }
4739
- wss.close();
4740
- for (const { router } of mailboxGateways.values()) router.close();
4741
- httpServer.close(() => {
4742
- const mailboxCloses = [...mailboxGateways.values()].map(
4743
- ({ mailbox }) => mailbox.close().catch(() => void 0)
4744
- );
4745
- mailboxGateways.clear();
4746
- mailboxGatewayLastUsed.clear();
4747
- void Promise.all([
4748
- ...mailboxCloses,
4749
- persistence.eventLog.drain(),
4750
- persistence.snapshotStore.drain(),
4751
- persistence.timeseries.drain(),
4752
- persistence.kanban.drain()
4753
- ]).then(() => clearHqRuntimeMarker(dataDir, hqUrl)).catch(() => void 0).finally(() => res());
4754
- });
4845
+ close: createHqServerShutdown({
4846
+ cleanupTimer,
4847
+ browserHeartbeatTimer,
4848
+ timeseriesFlushTimer,
4849
+ sessionCleanupTimer,
4850
+ loginAttempts,
4851
+ stopAlertEngine,
4852
+ authWatcher,
4853
+ snapshotBroadcaster,
4854
+ bootstrapStore,
4855
+ persistence,
4856
+ browsers,
4857
+ clients,
4858
+ wss,
4859
+ mailboxManager,
4860
+ httpServer,
4861
+ dataDir,
4862
+ hqUrl
4755
4863
  })
4756
4864
  };
4757
4865
  writeHqStartupInfo((line) => console.log(line.trimEnd()), {
@@ -4774,7 +4882,7 @@ export {
4774
4882
  readLocalSubagentTranscript2 as readLocalSubagentTranscript,
4775
4883
  DEFAULT_HOST,
4776
4884
  DEFAULT_PORT,
4777
- MAX_EVENT_LOG2 as MAX_EVENT_LOG,
4885
+ MAX_EVENT_LOG3 as MAX_EVENT_LOG,
4778
4886
  MAX_NON_STRICT_PORT_SCAN,
4779
4887
  CLIENT_TTL_MS,
4780
4888
  CLIENT_CLEANUP_INTERVAL_MS,
@@ -4783,4 +4891,4 @@ export {
4783
4891
  startHqServer,
4784
4892
  HqInsecureExposureError2 as HqInsecureExposureError
4785
4893
  };
4786
- //# sourceMappingURL=chunk-RHLQT3EV.js.map
4894
+ //# sourceMappingURL=chunk-CB35UVXO.js.map