@wrongstack/cli 0.306.3 → 0.307.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auth-L3WGSGDX.js → auth-TVOOHL4Q.js} +45 -106
- package/dist/boot/short-circuit-flags.d.ts +2 -2
- package/dist/chimera-cascade-evidence.d.ts +1 -1
- package/dist/chunk-2VMJ3E5Z.js +71 -0
- package/dist/{chunk-5N34EWJR.js → chunk-2ZNBRZ4W.js} +2 -2
- package/dist/{chunk-RHLQT3EV.js → chunk-CB35UVXO.js} +1636 -1528
- package/dist/{chunk-YKBRFSGL.js → chunk-DOJLAULS.js} +28 -28
- package/dist/{chunk-JUHRI6OV.js → chunk-G7337TTK.js} +24 -5
- package/dist/{chunk-KXN5HZDL.js → chunk-PKHGLWUJ.js} +24 -6
- package/dist/{chunk-HITHEXE3.js → chunk-TOG7OMKJ.js} +2 -2
- package/dist/{chunk-ARWGQUB3.js → chunk-XSG33WHN.js} +15 -5
- package/dist/{cli-main-3PUWFKSL.js → cli-main-YDOZZOZA.js} +29026 -28205
- package/dist/cli-main.d.ts +0 -20
- package/dist/{execution-4IG5XKOL.js → execution-CGT6J5ZN.js} +306 -274
- package/dist/fleet/host-learning-scheduler.d.ts +27 -0
- package/dist/fleet/host-shadow-manager.d.ts +64 -0
- package/dist/fleet/host.d.ts +5 -140
- package/dist/{hq-ZK6PGN3W.js → hq-PA6RH2UW.js} +2 -2
- package/dist/hq-server/mailbox-gateway-manager.d.ts +32 -0
- package/dist/hq-server/routes/auth/bootstrap-routes.d.ts +5 -0
- package/dist/hq-server/routes/auth/common.d.ts +11 -0
- package/dist/hq-server/routes/auth/password-routes.d.ts +9 -0
- package/dist/hq-server/routes/auth/session-audit-routes.d.ts +6 -0
- package/dist/hq-server/routes/auth/totp-routes.d.ts +9 -0
- package/dist/hq-server/routes/auth-handlers.d.ts +5 -132
- package/dist/hq-server/routes/command-handlers.d.ts +7 -0
- package/dist/hq-server/routes/data-handlers.d.ts +8 -0
- package/dist/hq-server/routes/mailbox-handlers.d.ts +22 -0
- package/dist/hq-server/routes.d.ts +3 -3
- package/dist/hq-server/server-lifecycle.d.ts +37 -0
- package/dist/hq-server/snapshot.d.ts +6 -5
- package/dist/hq-server/types.d.ts +4 -0
- package/dist/hq-server/upgrade-handler.d.ts +34 -0
- package/dist/hq-server/ws-client-events.d.ts +25 -0
- package/dist/hq-server/ws-leader.d.ts +47 -0
- package/dist/hq-server/ws-resume.d.ts +27 -0
- package/dist/hq-server/ws.d.ts +4 -71
- package/dist/{hq-server-IVZLVCFV.js → hq-server-PBRU275L.js} +2 -2
- package/dist/hq-server.d.ts +0 -38
- package/dist/index.js +1267 -122
- package/dist/{mcp-JNWEQ6T2.js → mcp-CMLFHRG2.js} +19 -9
- package/dist/mcp-serve.d.ts +31 -1
- package/dist/{modeldiag-6VS5KDP5.js → modeldiag-ICMPHT5L.js} +868 -831
- package/dist/{project-manifest-GE25PW4S.js → project-manifest-YFTJ3D5N.js} +2 -2
- package/dist/{project-picker-BOCWHBXP.js → project-picker-R5TYAE3Q.js} +3 -3
- package/dist/{providers-models-HQN3AT3K.js → providers-models-4FAKM2WO.js} +4 -4
- package/dist/repl-eternal-loop.d.ts +8 -0
- package/dist/repl-goal-banner.d.ts +21 -0
- package/dist/slash-commands/brain-council.d.ts +36 -0
- package/dist/slash-commands/brain-status.d.ts +12 -0
- package/dist/slash-commands/fallback.d.ts +6 -0
- package/dist/slash-commands/kanban-board-handlers.d.ts +13 -0
- package/dist/slash-commands/kanban-lifecycle-diagnostics.d.ts +16 -0
- package/dist/slash-commands/kanban-task-subcommands.d.ts +8 -0
- package/dist/slash-commands/memory-audience.d.ts +14 -0
- package/dist/slash-commands/memory-gather.d.ts +9 -0
- package/dist/slash-commands/memory-stats.d.ts +5 -0
- package/dist/slash-commands/settings-mutations.d.ts +6 -0
- package/dist/slash-commands/settings-view.d.ts +5 -0
- package/dist/subcommands/handlers/modeldiag-bench.d.ts +10 -0
- package/dist/subcommands/handlers/modeldiag-eval.d.ts +27 -0
- package/dist/subcommands/handlers/modeldiag-test.d.ts +8 -0
- package/dist/{version-help-ASKQYXDJ.js → version-help-TFD6VPF3.js} +2 -2
- package/dist/vibe-protocol-wiring.d.ts +10 -0
- package/dist/webui-server/credential-watcher.d.ts +15 -0
- package/dist/webui-server/domain-handlers.d.ts +21 -0
- package/dist/webui-server/route-contexts.d.ts +41 -0
- package/dist/{webui-server-FTG7JAZU.js → webui-server-IPHGJGXE.js} +515 -437
- package/dist/wiring/cli-execute-builder.d.ts +88 -0
- package/dist/wiring/cli-heap-watchdog.d.ts +33 -0
- package/dist/wiring/cli-prompt-and-tools-setup.d.ts +25 -0
- package/dist/wiring/cli-slash-commands-setup.d.ts +79 -0
- package/dist/wiring/heap-watchdog-setup.d.ts +53 -0
- package/dist/wiring/metrics.d.ts +1 -0
- package/dist/wiring/replay-governance-setup.d.ts +31 -0
- 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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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/
|
|
762
|
-
import {
|
|
763
|
-
function
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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-
|
|
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
|
|
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(
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
1846
|
-
import
|
|
1752
|
+
// src/hq-server/routes/command-handlers.ts
|
|
1753
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
1847
1754
|
import {
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
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
|
-
|
|
2620
|
-
if (!
|
|
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" &&
|
|
2626
|
-
const auth =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
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
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
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
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
return;
|
|
3268
|
-
}
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
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
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
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
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
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
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
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
|
-
|
|
3302
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
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 !==
|
|
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 >
|
|
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 !==
|
|
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 ||
|
|
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:
|
|
4188
|
+
protocolVersion: HQ_PROTOCOL_VERSION2,
|
|
3816
4189
|
serverTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3817
4190
|
acceptedCapabilities,
|
|
3818
|
-
|
|
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((
|
|
3828
|
-
sendGuarded(ws, JSON.stringify({ type: "hq.kanban_snapshot", payload:
|
|
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:
|
|
4203
|
+
id: randomUUID5(),
|
|
3833
4204
|
type: "client.hello",
|
|
3834
|
-
schemaVersion:
|
|
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
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
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
|
-
|
|
3923
|
-
if (!parsedPayload.ok) {
|
|
4339
|
+
if (wsHostname && !isLoopbackHost3(wsHostname)) {
|
|
3924
4340
|
console.warn(
|
|
3925
4341
|
JSON.stringify({
|
|
3926
4342
|
level: "warn",
|
|
3927
|
-
event: "hq.
|
|
3928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
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
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
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
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
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
|
-
|
|
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-
|
|
4894
|
+
//# sourceMappingURL=chunk-CB35UVXO.js.map
|