@wrongstack/cli 1.0.26 → 1.0.27
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/{acp-LI665LO2.js → acp-M4565YUW.js} +30 -17
- package/dist/boot/dispatch-singleshot.d.ts +8 -0
- package/dist/boot/dispatch-webui.d.ts +2 -0
- package/dist/boot/goal-tui-default.d.ts +10 -0
- package/dist/{chunk-OAVBM55Q.js → chunk-ENRYW73H.js} +525 -379
- package/dist/chunk-HDMFKTT3.js +157 -0
- package/dist/{chunk-WQV2NP57.js → chunk-HFZH2XJN.js} +2 -2
- package/dist/{chunk-BEHGRNOQ.js → chunk-N57IU5FG.js} +9 -9
- package/dist/{chunk-2EBVTYJR.js → chunk-NRXWOMK7.js} +17 -3
- package/dist/{chunk-5SEYGOCH.js → chunk-RDGGWMTI.js} +2 -2
- package/dist/{chunk-KJS5543T.js → chunk-RGFWGUAB.js} +26 -14
- package/dist/{chunk-S4FFHDJG.js → chunk-SDJCLEK6.js} +6 -2
- package/dist/{cli-context-UVGAN3NO.js → cli-context-C7RBSWHF.js} +33 -6
- package/dist/{cli-main-TN3KZ2P6.js → cli-main-TQTH73ZK.js} +304 -345
- package/dist/{execution-7QIP6GRY.js → execution-3STBD2U7.js} +87 -33
- package/dist/execution-chimera-review.d.ts +18 -1
- package/dist/{hq-GLZ7QESM.js → hq-44F4OQH2.js} +5 -4
- package/dist/hq-fleet-control.d.ts +36 -0
- package/dist/hq-live-status.d.ts +22 -0
- package/dist/hq-publisher.d.ts +18 -0
- package/dist/hq-server/routes/command-handlers.d.ts +12 -1
- package/dist/hq-server/routes/data-handlers.d.ts +1 -1
- package/dist/hq-server/socket-credentials.d.ts +63 -0
- package/dist/hq-server/upgrade-handler.d.ts +2 -0
- package/dist/hq-server/ws.d.ts +24 -0
- package/dist/{hq-server-YTKDVQPY.js → hq-server-UIB24LU6.js} +2 -2
- package/dist/{import-claude-code-32PCSIQI.js → import-claude-code-AYG24RG2.js} +36 -4
- package/dist/index.js +3 -3
- package/dist/live-settings-input.d.ts +1 -0
- package/dist/{mailbox-serve-NW5VZG7R.js → mailbox-serve-GNGXEQ2R.js} +2 -2
- package/dist/{modeldiag-36VODVIH.js → modeldiag-W7XAE625.js} +2 -2
- package/dist/{remote-O5WXHLWV.js → remote-GB5VXCCT.js} +38 -23
- package/dist/{sessions-config-SXTTLNXA.js → sessions-config-XHLXJ5C3.js} +75 -6
- package/dist/{short-circuit-flags-74Y5N6CF.js → short-circuit-flags-5TNTJPQR.js} +2 -2
- package/dist/slash-commands/theme.d.ts +15 -1
- package/dist/subcommands/handlers/import-claude-code.d.ts +10 -0
- package/dist/subcommands/handlers/sessions-fleet.d.ts +0 -12
- package/dist/{subcommands-Q6ORSB2E.js → subcommands-BHQYLDSD.js} +2 -2
- package/dist/{webui-server-YTHLRFD2.js → webui-server-GCXIO3JY.js} +37 -30
- package/dist/webui-server-options.d.ts +10 -0
- package/dist/wiring/command-host-state.d.ts +2 -0
- package/dist/wiring/hq-telemetry.d.ts +12 -0
- package/package.json +29 -28
- package/dist/chunk-OWNC7LMD.js +0 -78
|
@@ -41,8 +41,7 @@ import {
|
|
|
41
41
|
HqAlertEngine,
|
|
42
42
|
HqBootstrapCodeStore,
|
|
43
43
|
HqCommandAuditLog,
|
|
44
|
-
hqTokenKey as
|
|
45
|
-
isTokenExpired as isTokenExpired4,
|
|
44
|
+
hqTokenKey as hqTokenKey4,
|
|
46
45
|
mintHqCookieSecret as mintHqCookieSecret2,
|
|
47
46
|
mutateHqAuthFile as mutateHqAuthFile4,
|
|
48
47
|
shouldCloseBrowserSocket,
|
|
@@ -50,7 +49,7 @@ import {
|
|
|
50
49
|
watchHqAuthFile
|
|
51
50
|
} from "@wrongstack/core/hq";
|
|
52
51
|
import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
|
|
53
|
-
import { WebSocket as
|
|
52
|
+
import { WebSocket as WebSocket4, WebSocketServer } from "ws";
|
|
54
53
|
|
|
55
54
|
// src/hq-recovery-html.ts
|
|
56
55
|
var HQ_RECOVERY_HTML = `<!doctype html>
|
|
@@ -76,270 +75,18 @@ var HQ_RECOVERY_HTML = `<!doctype html>
|
|
|
76
75
|
</html>`;
|
|
77
76
|
var HQ_HTML = HQ_RECOVERY_HTML;
|
|
78
77
|
|
|
79
|
-
// src/hq-server/auth.ts
|
|
80
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
81
|
-
import { isIP } from "node:net";
|
|
82
|
-
import { hqTokenVerifier, isLoopbackHost, isTokenExpired } from "@wrongstack/core/hq";
|
|
83
|
-
var HQ_SESSION_COOKIE = "hq.session";
|
|
84
|
-
var HQ_SESSION_COOKIE_SECURE = "__Host-hq.session";
|
|
85
|
-
function cookieName(secure) {
|
|
86
|
-
return secure ? HQ_SESSION_COOKIE_SECURE : HQ_SESSION_COOKIE;
|
|
87
|
-
}
|
|
88
|
-
var HQ_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
89
|
-
var HQ_SESSION_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
90
|
-
function setHqSecurityHeaders(res) {
|
|
91
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
92
|
-
res.setHeader("X-Frame-Options", "DENY");
|
|
93
|
-
res.setHeader("Referrer-Policy", "no-referrer");
|
|
94
|
-
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
95
|
-
res.setHeader(
|
|
96
|
-
"Content-Security-Policy",
|
|
97
|
-
[
|
|
98
|
-
"default-src 'self'",
|
|
99
|
-
"base-uri 'none'",
|
|
100
|
-
"object-src 'none'",
|
|
101
|
-
"frame-ancestors 'none'",
|
|
102
|
-
"form-action 'self'",
|
|
103
|
-
"script-src 'self' 'wasm-unsafe-eval'",
|
|
104
|
-
"style-src 'self' 'unsafe-inline'",
|
|
105
|
-
"font-src 'self' data:",
|
|
106
|
-
"img-src 'self' data:",
|
|
107
|
-
"connect-src 'self'"
|
|
108
|
-
].join("; ")
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
function hasTrustedRequestAuthority(req, boundHost, boundPort, trustedPublicOrigins) {
|
|
112
|
-
const rawHost = req.headers.host?.trim();
|
|
113
|
-
if (!rawHost) return false;
|
|
114
|
-
try {
|
|
115
|
-
const requestUrl = new URL(`http://${rawHost}`);
|
|
116
|
-
if (requestUrl.username || requestUrl.password || requestUrl.pathname !== "/" || requestUrl.search || requestUrl.hash) {
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
for (const origin of trustedPublicOrigins) {
|
|
120
|
-
const trusted = new URL(origin);
|
|
121
|
-
const normalizedRequest = new URL(`${trusted.protocol}//${rawHost}`);
|
|
122
|
-
if (trusted.host.toLowerCase() === normalizedRequest.host.toLowerCase()) return true;
|
|
123
|
-
}
|
|
124
|
-
const hostname = requestUrl.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
125
|
-
const requestPort = requestUrl.port || "80";
|
|
126
|
-
const portMatches = boundPort === void 0 || boundPort === 0 || requestPort === String(boundPort);
|
|
127
|
-
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
128
|
-
if (isLoopback) return portMatches;
|
|
129
|
-
if (isIP(hostname) !== 0) {
|
|
130
|
-
const normalizedBoundHost = boundHost?.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
131
|
-
const permitsIpHost = normalizedBoundHost === void 0 || normalizedBoundHost === "0.0.0.0" || normalizedBoundHost === "::" || normalizedBoundHost === hostname;
|
|
132
|
-
return permitsIpHost && portMatches;
|
|
133
|
-
}
|
|
134
|
-
return boundHost?.toLowerCase() === hostname && portMatches;
|
|
135
|
-
} catch {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function hasTrustedBrowserOrigin(req, boundHost, boundPort, trustedPublicOrigins = /* @__PURE__ */ new Set(), allowFileOrigin = false, credentialedSurface = false) {
|
|
140
|
-
if (!hasTrustedRequestAuthority(req, boundHost, boundPort, trustedPublicOrigins)) return false;
|
|
141
|
-
const origin = req.headers.origin;
|
|
142
|
-
if (origin === void 0) {
|
|
143
|
-
if (!credentialedSurface) {
|
|
144
|
-
return boundHost !== void 0 && isLoopbackHost(boundHost);
|
|
145
|
-
}
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
const parsed = new URL(origin);
|
|
150
|
-
if (parsed.protocol === "file:") return allowFileOrigin;
|
|
151
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
152
|
-
const rawRequestHost = req.headers.host?.trim();
|
|
153
|
-
if (!rawRequestHost) return false;
|
|
154
|
-
const requestHost = new URL(`${parsed.protocol}//${rawRequestHost}`).host.toLowerCase();
|
|
155
|
-
if (parsed.host.toLowerCase() !== requestHost) return false;
|
|
156
|
-
const registeredPublicOrigin = [...trustedPublicOrigins].some(
|
|
157
|
-
(trustedOrigin) => new URL(trustedOrigin).host.toLowerCase() === requestHost
|
|
158
|
-
);
|
|
159
|
-
return !registeredPublicOrigin || trustedPublicOrigins.has(parsed.origin.toLowerCase());
|
|
160
|
-
} catch {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function signHqSession(sessionId, secret) {
|
|
165
|
-
return createHmac("sha256", secret).update(sessionId).digest("hex");
|
|
166
|
-
}
|
|
167
|
-
function serializeHqSessionCookie(sessionId, secret) {
|
|
168
|
-
return `${sessionId}.${signHqSession(sessionId, secret)}`;
|
|
169
|
-
}
|
|
170
|
-
function parseHqSessionCookie(value, secret) {
|
|
171
|
-
const dot = value.indexOf(".");
|
|
172
|
-
if (dot === -1) return void 0;
|
|
173
|
-
const sessionId = value.slice(0, dot);
|
|
174
|
-
const sig = value.slice(dot + 1);
|
|
175
|
-
if (!sessionId || !sig) return void 0;
|
|
176
|
-
const expected = signHqSession(sessionId, secret);
|
|
177
|
-
try {
|
|
178
|
-
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return void 0;
|
|
179
|
-
} catch {
|
|
180
|
-
return void 0;
|
|
181
|
-
}
|
|
182
|
-
return sessionId;
|
|
183
|
-
}
|
|
184
|
-
function parseCookieHeader(cookieHeader) {
|
|
185
|
-
const out = {};
|
|
186
|
-
if (typeof cookieHeader !== "string") return out;
|
|
187
|
-
for (const part of cookieHeader.split(";")) {
|
|
188
|
-
const idx = part.indexOf("=");
|
|
189
|
-
if (idx === -1) continue;
|
|
190
|
-
const key = part.slice(0, idx).trim();
|
|
191
|
-
const value = part.slice(idx + 1).trim();
|
|
192
|
-
if (key) {
|
|
193
|
-
try {
|
|
194
|
-
out[key] = decodeURIComponent(value);
|
|
195
|
-
} catch {
|
|
196
|
-
out[key] = value;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return out;
|
|
201
|
-
}
|
|
202
|
-
function readHqSessionCookie(cookieHeader) {
|
|
203
|
-
const cookies = parseCookieHeader(cookieHeader);
|
|
204
|
-
return cookies[HQ_SESSION_COOKIE_SECURE] ?? cookies[HQ_SESSION_COOKIE];
|
|
205
|
-
}
|
|
206
|
-
function setHqSessionCookie(res, value, secure) {
|
|
207
|
-
const name = cookieName(secure);
|
|
208
|
-
const parts = [
|
|
209
|
-
`${name}=${encodeURIComponent(value)}`,
|
|
210
|
-
"Path=/",
|
|
211
|
-
"HttpOnly",
|
|
212
|
-
"SameSite=Lax",
|
|
213
|
-
`Max-Age=${Math.floor(HQ_SESSION_MAX_AGE_MS / 1e3)}`
|
|
214
|
-
];
|
|
215
|
-
if (secure) parts.push("Secure");
|
|
216
|
-
res.setHeader("Set-Cookie", parts.join("; "));
|
|
217
|
-
}
|
|
218
|
-
function clearHqSessionCookie(res, secure) {
|
|
219
|
-
const name = cookieName(secure);
|
|
220
|
-
const parts = [`${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`];
|
|
221
|
-
if (secure) parts.push("Secure");
|
|
222
|
-
res.setHeader("Set-Cookie", parts.join("; "));
|
|
223
|
-
}
|
|
224
|
-
function isTokenAuth(auth) {
|
|
225
|
-
return auth !== void 0 && auth.kind === "token";
|
|
226
|
-
}
|
|
227
|
-
function isCookieAuth(auth) {
|
|
228
|
-
return auth !== void 0 && auth.kind === "cookie";
|
|
229
|
-
}
|
|
230
|
-
function hqAuthRequired(mutableAuth, requireBrowserAuth) {
|
|
231
|
-
return (requireBrowserAuth ?? mutableAuth.requireBrowserAuth) === true || mutableAuth.requireAuthFloor === true || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash !== void 0;
|
|
232
|
-
}
|
|
233
|
-
function hqClientAuthRequired(mutableAuth) {
|
|
234
|
-
return mutableAuth.clientTokens.size > 0 || hqAuthRequired(mutableAuth);
|
|
235
|
-
}
|
|
236
|
-
function extractBrowserToken(req, url) {
|
|
237
|
-
const queryToken = url.searchParams.get("token");
|
|
238
|
-
if (queryToken) {
|
|
239
|
-
const requestHost = (req.headers.host ?? "").trim();
|
|
240
|
-
let hostname = "";
|
|
241
|
-
try {
|
|
242
|
-
hostname = new URL(`http://${requestHost}`).hostname;
|
|
243
|
-
} catch {
|
|
244
|
-
}
|
|
245
|
-
if (hostname && isLoopbackHost(hostname)) {
|
|
246
|
-
console.warn(
|
|
247
|
-
JSON.stringify({
|
|
248
|
-
level: "warn",
|
|
249
|
-
event: "hq.token_from_query_param",
|
|
250
|
-
message: "Browser token accepted from URL query parameter \u2014 token can leak through browser history, server access logs, and Referer headers.",
|
|
251
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
252
|
-
})
|
|
253
|
-
);
|
|
254
|
-
return queryToken;
|
|
255
|
-
}
|
|
256
|
-
console.warn(
|
|
257
|
-
JSON.stringify({
|
|
258
|
-
level: "warn",
|
|
259
|
-
event: "hq.token_from_query_param_rejected",
|
|
260
|
-
message: "Browser token in URL query rejected on a non-loopback request \u2014 send it in the Authorization header instead.",
|
|
261
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
262
|
-
})
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
const auth = req.headers.authorization;
|
|
266
|
-
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
|
267
|
-
return auth.slice(7).trim();
|
|
268
|
-
}
|
|
269
|
-
return void 0;
|
|
270
|
-
}
|
|
271
|
-
function timingSafeTokenMatch(tokens, supplied) {
|
|
272
|
-
if (!supplied) return void 0;
|
|
273
|
-
const b = Buffer.from(hqTokenVerifier(supplied));
|
|
274
|
-
for (const candidate of tokens) {
|
|
275
|
-
const a = Buffer.from(candidate);
|
|
276
|
-
if (a.length === b.length && timingSafeEqual(a, b)) return candidate;
|
|
277
|
-
}
|
|
278
|
-
return void 0;
|
|
279
|
-
}
|
|
280
|
-
function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
|
|
281
|
-
const token = extractBrowserToken(req, url);
|
|
282
|
-
if (token) {
|
|
283
|
-
const matchedToken = timingSafeTokenMatch(mutableAuth.browserTokens, token);
|
|
284
|
-
if (matchedToken) {
|
|
285
|
-
const obj = mutableAuth.browserTokenObjs.get(matchedToken);
|
|
286
|
-
if (isTokenExpired(obj)) return void 0;
|
|
287
|
-
const ctx = {
|
|
288
|
-
kind: "token",
|
|
289
|
-
token: matchedToken,
|
|
290
|
-
id: obj?.id ?? "unknown"
|
|
291
|
-
};
|
|
292
|
-
if (obj?.capabilities !== void 0) ctx.capabilities = obj.capabilities;
|
|
293
|
-
return ctx;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
if (mutableAuth.cookieSecret) {
|
|
297
|
-
const raw = readHqSessionCookie(req.headers.cookie);
|
|
298
|
-
if (raw) {
|
|
299
|
-
const sessionId = parseHqSessionCookie(raw, mutableAuth.cookieSecret);
|
|
300
|
-
if (sessionId) {
|
|
301
|
-
const session = sessions.get(sessionId);
|
|
302
|
-
const now = Date.now();
|
|
303
|
-
if (session && now - session.createdAt < HQ_SESSION_MAX_AGE_MS) {
|
|
304
|
-
if (session.pending2fa) return void 0;
|
|
305
|
-
if (now - session.lastSeenAt > HQ_SESSION_IDLE_TIMEOUT_MS) {
|
|
306
|
-
sessions.delete(sessionId);
|
|
307
|
-
return void 0;
|
|
308
|
-
}
|
|
309
|
-
session.lastSeenAt = now;
|
|
310
|
-
if (session.kind === "token" && session.tokenId !== void 0) {
|
|
311
|
-
const liveToken = [...mutableAuth.browserTokenObjs.values()].find(
|
|
312
|
-
(obj) => obj.id === session.tokenId
|
|
313
|
-
);
|
|
314
|
-
if (!liveToken) {
|
|
315
|
-
sessions.delete(sessionId);
|
|
316
|
-
return void 0;
|
|
317
|
-
}
|
|
318
|
-
const ctx = { kind: "cookie", tokenId: session.tokenId };
|
|
319
|
-
if (liveToken.capabilities !== void 0) ctx.capabilities = liveToken.capabilities;
|
|
320
|
-
return ctx;
|
|
321
|
-
}
|
|
322
|
-
return session.capabilities === void 0 ? { kind: "cookie" } : { kind: "cookie", capabilities: session.capabilities };
|
|
323
|
-
}
|
|
324
|
-
if (session) sessions.delete(sessionId);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return void 0;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
78
|
// src/hq-server/auth-state.ts
|
|
332
79
|
import {
|
|
333
80
|
DEFAULT_HQ_REDACTION_POLICY,
|
|
334
81
|
findRevokedTokenKeys,
|
|
335
82
|
hqAuthContentHash,
|
|
336
83
|
hqTokenKey,
|
|
337
|
-
isTokenExpired
|
|
84
|
+
isTokenExpired,
|
|
338
85
|
logHqAuthAudit
|
|
339
86
|
} from "@wrongstack/core/hq";
|
|
340
87
|
var TOKEN_EXPIRY_WARNING_MS = 24 * 60 * 60 * 1e3;
|
|
341
88
|
function liveTokens(list) {
|
|
342
|
-
return (list ?? []).filter((token) => !
|
|
89
|
+
return (list ?? []).filter((token) => !isTokenExpired(token));
|
|
343
90
|
}
|
|
344
91
|
function projectAuthFile(mutableAuth, next) {
|
|
345
92
|
mutableAuth.operatorPolicy = {
|
|
@@ -380,7 +127,7 @@ function createHqAuthState(authFile, dataDir, opts = {}) {
|
|
|
380
127
|
tokenStats: () => {
|
|
381
128
|
const now = Date.now();
|
|
382
129
|
const all = [...rawBrowserTokens, ...rawClientTokens];
|
|
383
|
-
const live = all.filter((token) => !
|
|
130
|
+
const live = all.filter((token) => !isTokenExpired(token, now));
|
|
384
131
|
return {
|
|
385
132
|
browserTotal: mutableAuth.browserTokens.size,
|
|
386
133
|
clientTotal: mutableAuth.clientTokens.size,
|
|
@@ -424,10 +171,10 @@ function browserTokenMap(tokens) {
|
|
|
424
171
|
}
|
|
425
172
|
function auditPrunedTokens(scope, previous, next, authFile, dataDir) {
|
|
426
173
|
const previouslyLive = new Set(
|
|
427
|
-
previous.filter((token) => !
|
|
174
|
+
previous.filter((token) => !isTokenExpired(token)).map(({ id }) => id)
|
|
428
175
|
);
|
|
429
176
|
const prunedCount = next.filter(
|
|
430
|
-
(token) =>
|
|
177
|
+
(token) => isTokenExpired(token) && previouslyLive.has(token.id)
|
|
431
178
|
).length;
|
|
432
179
|
if (prunedCount === 0) return;
|
|
433
180
|
const contentHash = hqAuthContentHash(authFile);
|
|
@@ -564,78 +311,330 @@ var LoginAttemptStore = class _LoginAttemptStore {
|
|
|
564
311
|
const credKey = _LoginAttemptStore.credentialKey(password);
|
|
565
312
|
this.set(credKey, backoff(this.store.get(credKey)));
|
|
566
313
|
}
|
|
567
|
-
return this.store.get(ip);
|
|
314
|
+
return this.store.get(ip);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Clear the IP entry — and the credential entry when a password is given —
|
|
318
|
+
* on successful login.
|
|
319
|
+
*/
|
|
320
|
+
clearOnSuccess(ip, password) {
|
|
321
|
+
this.delete(ip);
|
|
322
|
+
if (password !== void 0 && password.length > 0) {
|
|
323
|
+
this.delete(_LoginAttemptStore.credentialKey(password));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/** Number of tracked entries (for diagnostics). */
|
|
327
|
+
get size() {
|
|
328
|
+
return this.store.size;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Flush pending writes immediately. Called on server shutdown.
|
|
332
|
+
*/
|
|
333
|
+
async flush() {
|
|
334
|
+
if (this.writeTimer) {
|
|
335
|
+
clearTimeout(this.writeTimer);
|
|
336
|
+
this.writeTimer = void 0;
|
|
337
|
+
}
|
|
338
|
+
if (this.dirty) {
|
|
339
|
+
await this.writeToDisk();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
scheduleWrite() {
|
|
343
|
+
this.dirty = true;
|
|
344
|
+
if (this.writeTimer) return;
|
|
345
|
+
this.writeTimer = setTimeout(() => {
|
|
346
|
+
this.writeTimer = void 0;
|
|
347
|
+
void this.writeToDisk();
|
|
348
|
+
}, this.debounceMs);
|
|
349
|
+
this.writeTimer.unref?.();
|
|
350
|
+
}
|
|
351
|
+
async writeToDisk() {
|
|
352
|
+
this.dirty = false;
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
const cutoff = now - this.retentionMs;
|
|
355
|
+
const obj = {};
|
|
356
|
+
for (const [key, entry] of this.store) {
|
|
357
|
+
if (entry.lastAttempt > cutoff) {
|
|
358
|
+
if (!key.startsWith("cred:")) {
|
|
359
|
+
obj[key] = entry;
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
this.store.delete(key);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
await atomicWrite(this.filePath, JSON.stringify(obj, null, 2), { mode: 384 });
|
|
367
|
+
await restrictFilePermissions(this.filePath, { label: "hq-login-attempts" });
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Iterate entries (for cleanup timers). */
|
|
372
|
+
entries() {
|
|
373
|
+
return this.store.entries();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/hq-server/mailbox-gateway-manager.ts
|
|
378
|
+
import {
|
|
379
|
+
createMailboxHttpRouter,
|
|
380
|
+
createProjectMailbox,
|
|
381
|
+
MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
|
|
382
|
+
MailboxEventEmitter,
|
|
383
|
+
MailboxHttpRateLimiter
|
|
384
|
+
} from "@wrongstack/core/coordination";
|
|
385
|
+
import * as path6 from "node:path";
|
|
386
|
+
|
|
387
|
+
// src/hq-server/auth.ts
|
|
388
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
389
|
+
import { isIP } from "node:net";
|
|
390
|
+
import { hqTokenVerifier, isLoopbackHost, isTokenExpired as isTokenExpired2 } from "@wrongstack/core/hq";
|
|
391
|
+
var HQ_SESSION_COOKIE = "hq.session";
|
|
392
|
+
var HQ_SESSION_COOKIE_SECURE = "__Host-hq.session";
|
|
393
|
+
function cookieName(secure) {
|
|
394
|
+
return secure ? HQ_SESSION_COOKIE_SECURE : HQ_SESSION_COOKIE;
|
|
395
|
+
}
|
|
396
|
+
var HQ_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
397
|
+
var HQ_SESSION_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
398
|
+
function setHqSecurityHeaders(res) {
|
|
399
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
400
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
401
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
402
|
+
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
403
|
+
res.setHeader(
|
|
404
|
+
"Content-Security-Policy",
|
|
405
|
+
[
|
|
406
|
+
"default-src 'self'",
|
|
407
|
+
"base-uri 'none'",
|
|
408
|
+
"object-src 'none'",
|
|
409
|
+
"frame-ancestors 'none'",
|
|
410
|
+
"form-action 'self'",
|
|
411
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
412
|
+
"style-src 'self' 'unsafe-inline'",
|
|
413
|
+
"font-src 'self' data:",
|
|
414
|
+
"img-src 'self' data:",
|
|
415
|
+
"connect-src 'self'"
|
|
416
|
+
].join("; ")
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
function hasTrustedRequestAuthority(req, boundHost, boundPort, trustedPublicOrigins) {
|
|
420
|
+
const rawHost = req.headers.host?.trim();
|
|
421
|
+
if (!rawHost) return false;
|
|
422
|
+
try {
|
|
423
|
+
const requestUrl = new URL(`http://${rawHost}`);
|
|
424
|
+
if (requestUrl.username || requestUrl.password || requestUrl.pathname !== "/" || requestUrl.search || requestUrl.hash) {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
for (const origin of trustedPublicOrigins) {
|
|
428
|
+
const trusted = new URL(origin);
|
|
429
|
+
const normalizedRequest = new URL(`${trusted.protocol}//${rawHost}`);
|
|
430
|
+
if (trusted.host.toLowerCase() === normalizedRequest.host.toLowerCase()) return true;
|
|
431
|
+
}
|
|
432
|
+
const hostname = requestUrl.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
433
|
+
const requestPort = requestUrl.port || "80";
|
|
434
|
+
const portMatches = boundPort === void 0 || boundPort === 0 || requestPort === String(boundPort);
|
|
435
|
+
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
436
|
+
if (isLoopback) return portMatches;
|
|
437
|
+
if (isIP(hostname) !== 0) {
|
|
438
|
+
const normalizedBoundHost = boundHost?.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
439
|
+
const permitsIpHost = normalizedBoundHost === void 0 || normalizedBoundHost === "0.0.0.0" || normalizedBoundHost === "::" || normalizedBoundHost === hostname;
|
|
440
|
+
return permitsIpHost && portMatches;
|
|
441
|
+
}
|
|
442
|
+
return boundHost?.toLowerCase() === hostname && portMatches;
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function hasTrustedBrowserOrigin(req, boundHost, boundPort, trustedPublicOrigins = /* @__PURE__ */ new Set(), allowFileOrigin = false, credentialedSurface = false) {
|
|
448
|
+
if (!hasTrustedRequestAuthority(req, boundHost, boundPort, trustedPublicOrigins)) return false;
|
|
449
|
+
const origin = req.headers.origin;
|
|
450
|
+
if (origin === void 0) {
|
|
451
|
+
if (!credentialedSurface) {
|
|
452
|
+
return boundHost !== void 0 && isLoopbackHost(boundHost);
|
|
453
|
+
}
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const parsed = new URL(origin);
|
|
458
|
+
if (parsed.protocol === "file:") return allowFileOrigin;
|
|
459
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
460
|
+
const rawRequestHost = req.headers.host?.trim();
|
|
461
|
+
if (!rawRequestHost) return false;
|
|
462
|
+
const requestHost = new URL(`${parsed.protocol}//${rawRequestHost}`).host.toLowerCase();
|
|
463
|
+
if (parsed.host.toLowerCase() !== requestHost) return false;
|
|
464
|
+
const registeredPublicOrigin = [...trustedPublicOrigins].some(
|
|
465
|
+
(trustedOrigin) => new URL(trustedOrigin).host.toLowerCase() === requestHost
|
|
466
|
+
);
|
|
467
|
+
return !registeredPublicOrigin || trustedPublicOrigins.has(parsed.origin.toLowerCase());
|
|
468
|
+
} catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function signHqSession(sessionId, secret) {
|
|
473
|
+
return createHmac("sha256", secret).update(sessionId).digest("hex");
|
|
474
|
+
}
|
|
475
|
+
function serializeHqSessionCookie(sessionId, secret) {
|
|
476
|
+
return `${sessionId}.${signHqSession(sessionId, secret)}`;
|
|
477
|
+
}
|
|
478
|
+
function parseHqSessionCookie(value, secret) {
|
|
479
|
+
const dot = value.indexOf(".");
|
|
480
|
+
if (dot === -1) return void 0;
|
|
481
|
+
const sessionId = value.slice(0, dot);
|
|
482
|
+
const sig = value.slice(dot + 1);
|
|
483
|
+
if (!sessionId || !sig) return void 0;
|
|
484
|
+
const expected = signHqSession(sessionId, secret);
|
|
485
|
+
try {
|
|
486
|
+
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return void 0;
|
|
487
|
+
} catch {
|
|
488
|
+
return void 0;
|
|
489
|
+
}
|
|
490
|
+
return sessionId;
|
|
491
|
+
}
|
|
492
|
+
function parseCookieHeader(cookieHeader) {
|
|
493
|
+
const out = {};
|
|
494
|
+
if (typeof cookieHeader !== "string") return out;
|
|
495
|
+
for (const part of cookieHeader.split(";")) {
|
|
496
|
+
const idx = part.indexOf("=");
|
|
497
|
+
if (idx === -1) continue;
|
|
498
|
+
const key = part.slice(0, idx).trim();
|
|
499
|
+
const value = part.slice(idx + 1).trim();
|
|
500
|
+
if (key) {
|
|
501
|
+
try {
|
|
502
|
+
out[key] = decodeURIComponent(value);
|
|
503
|
+
} catch {
|
|
504
|
+
out[key] = value;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
568
507
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
function readHqSessionCookie(cookieHeader) {
|
|
511
|
+
const cookies = parseCookieHeader(cookieHeader);
|
|
512
|
+
return cookies[HQ_SESSION_COOKIE_SECURE] ?? cookies[HQ_SESSION_COOKIE];
|
|
513
|
+
}
|
|
514
|
+
function setHqSessionCookie(res, value, secure) {
|
|
515
|
+
const name = cookieName(secure);
|
|
516
|
+
const parts = [
|
|
517
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
518
|
+
"Path=/",
|
|
519
|
+
"HttpOnly",
|
|
520
|
+
"SameSite=Lax",
|
|
521
|
+
`Max-Age=${Math.floor(HQ_SESSION_MAX_AGE_MS / 1e3)}`
|
|
522
|
+
];
|
|
523
|
+
if (secure) parts.push("Secure");
|
|
524
|
+
res.setHeader("Set-Cookie", parts.join("; "));
|
|
525
|
+
}
|
|
526
|
+
function clearHqSessionCookie(res, secure) {
|
|
527
|
+
const name = cookieName(secure);
|
|
528
|
+
const parts = [`${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`];
|
|
529
|
+
if (secure) parts.push("Secure");
|
|
530
|
+
res.setHeader("Set-Cookie", parts.join("; "));
|
|
531
|
+
}
|
|
532
|
+
function isTokenAuth(auth) {
|
|
533
|
+
return auth !== void 0 && auth.kind === "token";
|
|
534
|
+
}
|
|
535
|
+
function isCookieAuth(auth) {
|
|
536
|
+
return auth !== void 0 && auth.kind === "cookie";
|
|
537
|
+
}
|
|
538
|
+
function hqAuthRequired(mutableAuth, requireBrowserAuth) {
|
|
539
|
+
return (requireBrowserAuth ?? mutableAuth.requireBrowserAuth) === true || mutableAuth.requireAuthFloor === true || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash !== void 0;
|
|
540
|
+
}
|
|
541
|
+
function hqClientAuthRequired(mutableAuth) {
|
|
542
|
+
return mutableAuth.clientTokens.size > 0 || hqAuthRequired(mutableAuth);
|
|
543
|
+
}
|
|
544
|
+
function extractBrowserToken(req, url) {
|
|
545
|
+
const queryToken = url.searchParams.get("token");
|
|
546
|
+
if (queryToken) {
|
|
547
|
+
const requestHost = (req.headers.host ?? "").trim();
|
|
548
|
+
let hostname = "";
|
|
549
|
+
try {
|
|
550
|
+
hostname = new URL(`http://${requestHost}`).hostname;
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
if (hostname && isLoopbackHost(hostname)) {
|
|
554
|
+
console.warn(
|
|
555
|
+
JSON.stringify({
|
|
556
|
+
level: "warn",
|
|
557
|
+
event: "hq.token_from_query_param",
|
|
558
|
+
message: "Browser token accepted from URL query parameter \u2014 token can leak through browser history, server access logs, and Referer headers.",
|
|
559
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
return queryToken;
|
|
577
563
|
}
|
|
564
|
+
console.warn(
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
level: "warn",
|
|
567
|
+
event: "hq.token_from_query_param_rejected",
|
|
568
|
+
message: "Browser token in URL query rejected on a non-loopback request \u2014 send it in the Authorization header instead.",
|
|
569
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
570
|
+
})
|
|
571
|
+
);
|
|
578
572
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
return
|
|
573
|
+
const auth = req.headers.authorization;
|
|
574
|
+
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
|
575
|
+
return auth.slice(7).trim();
|
|
582
576
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (this.dirty) {
|
|
592
|
-
await this.writeToDisk();
|
|
593
|
-
}
|
|
577
|
+
return void 0;
|
|
578
|
+
}
|
|
579
|
+
function timingSafeTokenMatch(tokens, supplied) {
|
|
580
|
+
if (!supplied) return void 0;
|
|
581
|
+
const b = Buffer.from(hqTokenVerifier(supplied));
|
|
582
|
+
for (const candidate of tokens) {
|
|
583
|
+
const a = Buffer.from(candidate);
|
|
584
|
+
if (a.length === b.length && timingSafeEqual(a, b)) return candidate;
|
|
594
585
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
|
|
589
|
+
const token = extractBrowserToken(req, url);
|
|
590
|
+
if (token) {
|
|
591
|
+
const matchedToken = timingSafeTokenMatch(mutableAuth.browserTokens, token);
|
|
592
|
+
if (matchedToken) {
|
|
593
|
+
const obj = mutableAuth.browserTokenObjs.get(matchedToken);
|
|
594
|
+
if (isTokenExpired2(obj)) return void 0;
|
|
595
|
+
const ctx = {
|
|
596
|
+
kind: "token",
|
|
597
|
+
token: matchedToken,
|
|
598
|
+
id: obj?.id ?? "unknown"
|
|
599
|
+
};
|
|
600
|
+
if (obj?.capabilities !== void 0) ctx.capabilities = obj.capabilities;
|
|
601
|
+
return ctx;
|
|
602
|
+
}
|
|
603
603
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
|
|
604
|
+
if (mutableAuth.cookieSecret) {
|
|
605
|
+
const raw = readHqSessionCookie(req.headers.cookie);
|
|
606
|
+
if (raw) {
|
|
607
|
+
const sessionId = parseHqSessionCookie(raw, mutableAuth.cookieSecret);
|
|
608
|
+
if (sessionId) {
|
|
609
|
+
const session = sessions.get(sessionId);
|
|
610
|
+
const now = Date.now();
|
|
611
|
+
if (session && now - session.createdAt < HQ_SESSION_MAX_AGE_MS) {
|
|
612
|
+
if (session.pending2fa) return void 0;
|
|
613
|
+
if (now - session.lastSeenAt > HQ_SESSION_IDLE_TIMEOUT_MS) {
|
|
614
|
+
sessions.delete(sessionId);
|
|
615
|
+
return void 0;
|
|
616
|
+
}
|
|
617
|
+
session.lastSeenAt = now;
|
|
618
|
+
if (session.kind === "token" && session.tokenId !== void 0) {
|
|
619
|
+
const liveToken = [...mutableAuth.browserTokenObjs.values()].find(
|
|
620
|
+
(obj) => obj.id === session.tokenId
|
|
621
|
+
);
|
|
622
|
+
if (!liveToken || isTokenExpired2(liveToken)) {
|
|
623
|
+
sessions.delete(sessionId);
|
|
624
|
+
return void 0;
|
|
625
|
+
}
|
|
626
|
+
const ctx = { kind: "cookie", tokenId: session.tokenId };
|
|
627
|
+
if (liveToken.capabilities !== void 0) ctx.capabilities = liveToken.capabilities;
|
|
628
|
+
return ctx;
|
|
629
|
+
}
|
|
630
|
+
return session.capabilities === void 0 ? { kind: "cookie" } : { kind: "cookie", capabilities: session.capabilities };
|
|
613
631
|
}
|
|
614
|
-
|
|
615
|
-
this.store.delete(key);
|
|
632
|
+
if (session) sessions.delete(sessionId);
|
|
616
633
|
}
|
|
617
634
|
}
|
|
618
|
-
try {
|
|
619
|
-
await atomicWrite(this.filePath, JSON.stringify(obj, null, 2), { mode: 384 });
|
|
620
|
-
await restrictFilePermissions(this.filePath, { label: "hq-login-attempts" });
|
|
621
|
-
} catch {
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
/** Iterate entries (for cleanup timers). */
|
|
625
|
-
entries() {
|
|
626
|
-
return this.store.entries();
|
|
627
635
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
// src/hq-server/mailbox-gateway-manager.ts
|
|
631
|
-
import {
|
|
632
|
-
createMailboxHttpRouter,
|
|
633
|
-
createProjectMailbox,
|
|
634
|
-
MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
|
|
635
|
-
MailboxEventEmitter,
|
|
636
|
-
MailboxHttpRateLimiter
|
|
637
|
-
} from "@wrongstack/core/coordination";
|
|
638
|
-
import * as path6 from "node:path";
|
|
636
|
+
return void 0;
|
|
637
|
+
}
|
|
639
638
|
|
|
640
639
|
// src/hq-static-serve.ts
|
|
641
640
|
import * as fs2 from "node:fs";
|
|
@@ -886,7 +885,12 @@ async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, require
|
|
|
886
885
|
...publicOrigin !== void 0 ? { publicOrigin } : {},
|
|
887
886
|
secureCookies: secureCookies === true,
|
|
888
887
|
loggedIn: auth !== void 0 || localOpenMode,
|
|
889
|
-
authKind: isCookieAuth(auth) ? "password" : isTokenAuth(auth) ? "token" : localOpenMode ? "open" : void 0
|
|
888
|
+
authKind: isCookieAuth(auth) ? "password" : isTokenAuth(auth) ? "token" : localOpenMode ? "open" : void 0,
|
|
889
|
+
// What this credential may do, when it is narrower than full access
|
|
890
|
+
// (absent = unrestricted). The mobile login is deliberately
|
|
891
|
+
// `control.enqueue`-only; without this the dashboard could not know,
|
|
892
|
+
// offered Allow/Deny buttons, and every tap came back 403.
|
|
893
|
+
...auth?.capabilities !== void 0 ? { capabilities: auth.capabilities } : {}
|
|
890
894
|
})
|
|
891
895
|
);
|
|
892
896
|
}
|
|
@@ -2187,6 +2191,12 @@ async function projectDirectoryExists(globalRoot, projectId) {
|
|
|
2187
2191
|
return false;
|
|
2188
2192
|
}
|
|
2189
2193
|
}
|
|
2194
|
+
function warnProjectUnavailable(projectId, reason) {
|
|
2195
|
+
process.stderr.write(
|
|
2196
|
+
`${JSON.stringify({ level: "warn", event: "hq.project_unavailable", projectId, reason })}
|
|
2197
|
+
`
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2190
2200
|
function resolveHqProjectRoot(globalRoot, ids) {
|
|
2191
2201
|
const fn = async () => {
|
|
2192
2202
|
const { getSessionRegistry } = await import("@wrongstack/core/storage");
|
|
@@ -2198,7 +2208,13 @@ function resolveHqProjectRoot(globalRoot, ids) {
|
|
|
2198
2208
|
}
|
|
2199
2209
|
if (typeof ids.projectId === "string") {
|
|
2200
2210
|
const { createHash: createHash2 } = await import("node:crypto");
|
|
2201
|
-
const
|
|
2211
|
+
const probeFailures = [];
|
|
2212
|
+
const all = await registry.list({
|
|
2213
|
+
onProjectFailure: (slug, phase, error) => {
|
|
2214
|
+
const code = error?.code ?? "error";
|
|
2215
|
+
probeFailures.push(`${slug}/${phase}/${code}`);
|
|
2216
|
+
}
|
|
2217
|
+
}).catch(() => []);
|
|
2202
2218
|
const projectIds = /* @__PURE__ */ new Map();
|
|
2203
2219
|
const projectIdForRoot = (projectRoot) => {
|
|
2204
2220
|
const cached = projectIds.get(projectRoot);
|
|
@@ -2212,10 +2228,15 @@ function resolveHqProjectRoot(globalRoot, ids) {
|
|
|
2212
2228
|
);
|
|
2213
2229
|
if (match) return { status: "found", projectRoot: match.projectRoot };
|
|
2214
2230
|
if (await projectDirectoryExists(globalRoot, ids.projectId)) {
|
|
2231
|
+
warnProjectUnavailable(
|
|
2232
|
+
ids.projectId,
|
|
2233
|
+
probeFailures.length > 0 ? `probe-failed ${probeFailures.join(",")}` : "no-live-lease"
|
|
2234
|
+
);
|
|
2215
2235
|
return { status: "unavailable" };
|
|
2216
2236
|
}
|
|
2217
2237
|
}
|
|
2218
2238
|
} catch {
|
|
2239
|
+
warnProjectUnavailable(String(ids.projectId ?? ids.sessionId ?? "?"), "registry-error");
|
|
2219
2240
|
return { status: "unavailable" };
|
|
2220
2241
|
}
|
|
2221
2242
|
return { status: "unknown" };
|
|
@@ -2549,20 +2570,11 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2549
2570
|
res.end(JSON.stringify({ error: "unrecognized or malformed command", type: body.type }));
|
|
2550
2571
|
return;
|
|
2551
2572
|
}
|
|
2552
|
-
if ((validated.type === "approve" || validated.type === "answer-input") && isCookieAuth(auth)
|
|
2553
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2554
|
-
res.end(
|
|
2555
|
-
JSON.stringify({
|
|
2556
|
-
error: "forbidden: browser session lacks control.approve capability"
|
|
2557
|
-
})
|
|
2558
|
-
);
|
|
2559
|
-
return;
|
|
2560
|
-
}
|
|
2561
|
-
if ((validated.type === "approve" || validated.type === "answer-input") && isTokenAuth(auth) && auth.capabilities !== void 0 && !auth.capabilities.includes("control.approve")) {
|
|
2573
|
+
if ((validated.type === "approve" || validated.type === "answer-input") && (isCookieAuth(auth) || isTokenAuth(auth)) && !credentialMayAnswer(auth.capabilities, validated)) {
|
|
2562
2574
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2563
2575
|
res.end(
|
|
2564
2576
|
JSON.stringify({
|
|
2565
|
-
error: "forbidden: token lacks control.approve capability"
|
|
2577
|
+
error: isCookieAuth(auth) ? "forbidden: browser session lacks control.approve capability" : "forbidden: token lacks control.approve capability"
|
|
2566
2578
|
})
|
|
2567
2579
|
);
|
|
2568
2580
|
return;
|
|
@@ -2610,8 +2622,6 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2610
2622
|
return;
|
|
2611
2623
|
}
|
|
2612
2624
|
target.commandQueue.push(queued);
|
|
2613
|
-
if (target.commandQueue.length > 200)
|
|
2614
|
-
target.commandQueue.splice(0, target.commandQueue.length - 200);
|
|
2615
2625
|
const auditEntry = {
|
|
2616
2626
|
commandId,
|
|
2617
2627
|
type: validated.type,
|
|
@@ -2622,9 +2632,35 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2622
2632
|
};
|
|
2623
2633
|
auditLog.record(auditEntry);
|
|
2624
2634
|
broadcastCommandStatus(auditEntry, browsers);
|
|
2635
|
+
trimCommandQueue(target, browsers, auditLog);
|
|
2625
2636
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
2626
2637
|
res.end(JSON.stringify({ commandId, queued: true, clientId: target.clientId }));
|
|
2627
2638
|
}
|
|
2639
|
+
var MAX_QUEUED_COMMANDS_PER_CLIENT = 200;
|
|
2640
|
+
function trimCommandQueue(target, browsers, auditLog) {
|
|
2641
|
+
const overflow = target.commandQueue.length - MAX_QUEUED_COMMANDS_PER_CLIENT;
|
|
2642
|
+
if (overflow <= 0) return;
|
|
2643
|
+
const dropped = target.commandQueue.splice(0, overflow);
|
|
2644
|
+
const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2645
|
+
for (const command of dropped) {
|
|
2646
|
+
if (auditLog.get(command.commandId)?.status !== "queued") continue;
|
|
2647
|
+
const updated = auditLog.updateForClient(command.commandId, target.clientId, {
|
|
2648
|
+
status: "acked",
|
|
2649
|
+
ackStatus: "failed",
|
|
2650
|
+
ackMessage: "dropped from the queue: the client stopped polling for commands",
|
|
2651
|
+
ackedAt: droppedAt
|
|
2652
|
+
});
|
|
2653
|
+
const entry = updated ? auditLog.get(command.commandId) : void 0;
|
|
2654
|
+
if (entry !== void 0) broadcastCommandStatus(entry, browsers);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
var ONE_SHOT_DECISIONS = /* @__PURE__ */ new Set(["yes", "no"]);
|
|
2658
|
+
function credentialMayAnswer(capabilities, command) {
|
|
2659
|
+
if (capabilities === void 0 || capabilities.includes("control.approve")) return true;
|
|
2660
|
+
if (!capabilities.includes("control.approve.once")) return false;
|
|
2661
|
+
if (command.type === "answer-input") return true;
|
|
2662
|
+
return command.type === "approve" && ONE_SHOT_DECISIONS.has(command.decision);
|
|
2663
|
+
}
|
|
2628
2664
|
|
|
2629
2665
|
// src/hq-server/routes/data-handlers.ts
|
|
2630
2666
|
async function handleApiProjectDetail(_req, res, url, clients) {
|
|
@@ -2652,7 +2688,7 @@ async function handleApiProjectDetail(_req, res, url, clients) {
|
|
|
2652
2688
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2653
2689
|
res.end(JSON.stringify(detail));
|
|
2654
2690
|
}
|
|
2655
|
-
async function handleApiEvents(req, res, persistence) {
|
|
2691
|
+
async function handleApiEvents(req, res, persistence, resolveMachineClientIds) {
|
|
2656
2692
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2657
2693
|
const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
|
|
2658
2694
|
const limit = Math.min(5e3, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
|
|
@@ -2663,14 +2699,13 @@ async function handleApiEvents(req, res, persistence) {
|
|
|
2663
2699
|
const untilMsRaw = url.searchParams.get("until");
|
|
2664
2700
|
const sinceMs = sinceMsRaw !== null && sinceMsRaw.length > 0 && Number.isFinite(Number(sinceMsRaw)) ? Number(sinceMsRaw) : void 0;
|
|
2665
2701
|
const untilMs = untilMsRaw !== null && untilMsRaw.length > 0 && Number.isFinite(Number(untilMsRaw)) ? Number(untilMsRaw) : void 0;
|
|
2666
|
-
const
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
if (
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
return false;
|
|
2702
|
+
const clientsOnMachine = machineIdFilter !== void 0 ? resolveMachineClientIds?.(machineIdFilter) ?? /* @__PURE__ */ new Set() : void 0;
|
|
2703
|
+
const matches = (event) => {
|
|
2704
|
+
if (clientIdFilter !== void 0 && event.clientId !== clientIdFilter) return false;
|
|
2705
|
+
if (machineIdFilter !== void 0) {
|
|
2706
|
+
const payload = event.payload;
|
|
2707
|
+
const onMachine = payload?.machineId === machineIdFilter || payload?.client?.machineId === machineIdFilter || event.clientId !== void 0 && clientsOnMachine?.has(event.clientId) === true;
|
|
2708
|
+
if (!onMachine) return false;
|
|
2674
2709
|
}
|
|
2675
2710
|
if (sinceMs !== void 0 || untilMs !== void 0) {
|
|
2676
2711
|
const ts = event.timestamp !== void 0 ? Date.parse(event.timestamp) : Number.NaN;
|
|
@@ -2679,7 +2714,13 @@ async function handleApiEvents(req, res, persistence) {
|
|
|
2679
2714
|
if (untilMs !== void 0 && ts > untilMs) return false;
|
|
2680
2715
|
}
|
|
2681
2716
|
return true;
|
|
2682
|
-
}
|
|
2717
|
+
};
|
|
2718
|
+
const narrowed = clientIdFilter !== void 0 || machineIdFilter !== void 0 || sinceMs !== void 0 || untilMs !== void 0;
|
|
2719
|
+
const filtered = await persistence.eventLog.recent(
|
|
2720
|
+
limit,
|
|
2721
|
+
typeFilter,
|
|
2722
|
+
narrowed ? matches : void 0
|
|
2723
|
+
);
|
|
2683
2724
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2684
2725
|
res.end(JSON.stringify({ events: filtered, total: filtered.length }));
|
|
2685
2726
|
}
|
|
@@ -3109,7 +3150,10 @@ function createHqRouter(deps) {
|
|
|
3109
3150
|
secureCookies,
|
|
3110
3151
|
trustedProxyHops,
|
|
3111
3152
|
dataDir,
|
|
3112
|
-
|
|
3153
|
+
// Least privilege for the phone: send and steer, plus ONE-SHOT
|
|
3154
|
+
// answers to pending prompts (see `credentialMayAnswer`) — never
|
|
3155
|
+
// persistent approval policy, never account administration.
|
|
3156
|
+
url.pathname === "/api/mobile/login" ? ["control.enqueue", "control.approve.once"] : void 0
|
|
3113
3157
|
);
|
|
3114
3158
|
return;
|
|
3115
3159
|
}
|
|
@@ -3252,7 +3296,15 @@ function createHqRouter(deps) {
|
|
|
3252
3296
|
return;
|
|
3253
3297
|
}
|
|
3254
3298
|
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
3255
|
-
await handleApiEvents(req, res, persistence)
|
|
3299
|
+
await handleApiEvents(req, res, persistence, (machineId) => {
|
|
3300
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3301
|
+
for (const client of clients.values()) {
|
|
3302
|
+
if ((client.machineId || client.project.machineId) === machineId) {
|
|
3303
|
+
ids.add(client.clientId);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return ids;
|
|
3307
|
+
});
|
|
3256
3308
|
return;
|
|
3257
3309
|
}
|
|
3258
3310
|
if (url.pathname === "/api/trends/cost" && req.method === "GET") {
|
|
@@ -3724,20 +3776,80 @@ function createHqServerShutdown(params) {
|
|
|
3724
3776
|
});
|
|
3725
3777
|
}
|
|
3726
3778
|
|
|
3779
|
+
// src/hq-server/socket-credentials.ts
|
|
3780
|
+
import { hqTokenKey as hqTokenKey2, isTokenExpired as isTokenExpired4 } from "@wrongstack/core/hq";
|
|
3781
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
3782
|
+
function createHqSocketCredentialEnforcer(state) {
|
|
3783
|
+
const closeSessionSockets = (sessionId, reason) => {
|
|
3784
|
+
for (const [browser, boundSessionId] of state.browserSocketSessions) {
|
|
3785
|
+
if (boundSessionId === sessionId && browser.readyState === WebSocket2.OPEN) {
|
|
3786
|
+
browser.close(1008, reason);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
};
|
|
3790
|
+
const closeUnauthorizedClientSockets = () => {
|
|
3791
|
+
const clientAuthRequired = hqClientAuthRequired(state.mutableAuth);
|
|
3792
|
+
for (const [clientSocket, authToken] of state.clientSocketTokens) {
|
|
3793
|
+
const stillAuthorized = authToken === void 0 ? !clientAuthRequired : !isTokenExpired4(authToken) && state.mutableAuth.clientTokenObjs.has(hqTokenKey2(authToken));
|
|
3794
|
+
if (!stillAuthorized) clientSocket.close(1008, "Client authentication revoked");
|
|
3795
|
+
}
|
|
3796
|
+
};
|
|
3797
|
+
return {
|
|
3798
|
+
announceRevokedTokens(keys, ids) {
|
|
3799
|
+
const frame = JSON.stringify({ type: "hq.auth_revoked", revokedTokenKeys: keys });
|
|
3800
|
+
const revokedIds = new Set(ids);
|
|
3801
|
+
for (const ws of state.browsers) {
|
|
3802
|
+
if (ws.readyState !== WebSocket2.OPEN) continue;
|
|
3803
|
+
const sessionId = state.browserSocketSessions.get(ws);
|
|
3804
|
+
if (sessionId !== void 0) {
|
|
3805
|
+
const session = state.sessions.get(sessionId);
|
|
3806
|
+
const affected = session === void 0 || session.kind === "token" && (session.tokenId === void 0 || revokedIds.has(session.tokenId));
|
|
3807
|
+
if (!affected) continue;
|
|
3808
|
+
}
|
|
3809
|
+
ws.send(frame);
|
|
3810
|
+
}
|
|
3811
|
+
},
|
|
3812
|
+
closeUnauthorizedClientSockets,
|
|
3813
|
+
sweepExpiredSocketCredentials(now = Date.now()) {
|
|
3814
|
+
closeUnauthorizedClientSockets();
|
|
3815
|
+
for (const [browser, key] of state.browserSocketTokenKeys ?? []) {
|
|
3816
|
+
const token = state.mutableAuth.browserTokenObjs.get(key);
|
|
3817
|
+
const live = state.mutableAuth.browserTokens.has(key) && token !== void 0 && !isTokenExpired4(token, now);
|
|
3818
|
+
if (!live && browser.readyState === WebSocket2.OPEN) {
|
|
3819
|
+
browser.close(1008, "Browser token expired or revoked");
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
const expiredTokenIds = new Set(
|
|
3823
|
+
[...state.mutableAuth.browserTokenObjs.values()].filter((token) => isTokenExpired4(token, now)).map((token) => token.id)
|
|
3824
|
+
);
|
|
3825
|
+
for (const sessionId of new Set(state.sessions.keys())) {
|
|
3826
|
+
const session = state.sessions.get(sessionId);
|
|
3827
|
+
if (session === void 0) continue;
|
|
3828
|
+
const expired = now - session.createdAt >= HQ_SESSION_MAX_AGE_MS || session.kind === "token" && session.tokenId !== void 0 && expiredTokenIds.has(session.tokenId);
|
|
3829
|
+
if (!expired) continue;
|
|
3830
|
+
state.sessions.delete(sessionId);
|
|
3831
|
+
closeSessionSockets(sessionId, "Browser session expired");
|
|
3832
|
+
}
|
|
3833
|
+
},
|
|
3834
|
+
closeSessionSockets
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3727
3838
|
// src/hq-server/upgrade-handler.ts
|
|
3728
|
-
import { hqTokenVerifier as hqTokenVerifier2, isLoopbackHost as isLoopbackHost3 } from "@wrongstack/core/hq";
|
|
3839
|
+
import { hqTokenVerifier as hqTokenVerifier2, isLoopbackHost as isLoopbackHost3, isTokenExpired as isTokenExpired5 } from "@wrongstack/core/hq";
|
|
3729
3840
|
|
|
3730
3841
|
// src/hq-server/ws.ts
|
|
3731
3842
|
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3732
3843
|
import {
|
|
3733
3844
|
HQ_PROTOCOL_VERSION as HQ_PROTOCOL_VERSION2,
|
|
3845
|
+
hqTokenKey as hqTokenKey3,
|
|
3734
3846
|
parseHqFrame,
|
|
3735
3847
|
redactHqEvent as redactHqEvent2,
|
|
3736
3848
|
resolveHqRedactionPolicy,
|
|
3737
3849
|
tightenHqRedactionPolicy as tightenHqRedactionPolicy2,
|
|
3738
3850
|
tokenHasCapability as tokenHasCapability3
|
|
3739
3851
|
} from "@wrongstack/core/hq";
|
|
3740
|
-
import { WebSocket as
|
|
3852
|
+
import { WebSocket as WebSocket3 } from "ws";
|
|
3741
3853
|
|
|
3742
3854
|
// src/hq-server/ws-client-events.ts
|
|
3743
3855
|
import {
|
|
@@ -3826,9 +3938,8 @@ function handleIncomingClientEvent(ws, incomingEvent, clients, browsers, auth, s
|
|
|
3826
3938
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3827
3939
|
})
|
|
3828
3940
|
);
|
|
3829
|
-
});
|
|
3941
|
+
}).finally(() => broadcastEvent(event, browsers));
|
|
3830
3942
|
persistEvent(event);
|
|
3831
|
-
broadcastEvent(event, browsers);
|
|
3832
3943
|
return;
|
|
3833
3944
|
}
|
|
3834
3945
|
if (event.type === "mailbox.snapshot") {
|
|
@@ -4276,6 +4387,12 @@ function handleBrowser(ws, snapshotBroadcaster, browsers, eventLog) {
|
|
|
4276
4387
|
browsers.delete(ws);
|
|
4277
4388
|
});
|
|
4278
4389
|
}
|
|
4390
|
+
function undeliveredCommands(client, auditLog) {
|
|
4391
|
+
if (auditLog === void 0) return [...client.commandQueue];
|
|
4392
|
+
return client.commandQueue.filter(
|
|
4393
|
+
(queued) => auditLog.get(queued.commandId)?.status === "queued"
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4279
4396
|
function failUndeliveredCommands(lostClient, browsers, auditLog) {
|
|
4280
4397
|
if (auditLog === void 0 || lostClient.commandQueue.length === 0) return;
|
|
4281
4398
|
const disconnectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -4337,16 +4454,29 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
|
|
|
4337
4454
|
);
|
|
4338
4455
|
const declaredRedactionPolicy = resolveHqRedactionPolicy(payload.redactionPolicy);
|
|
4339
4456
|
const supersededLeaders = [];
|
|
4457
|
+
const inheritedCommands = [];
|
|
4340
4458
|
let inheritedLeader = false;
|
|
4341
4459
|
const isSessionSurface = acceptedCapabilities.includes("session.summary");
|
|
4460
|
+
const helloTokenKey = auth.token === void 0 ? void 0 : hqTokenKey3(auth.token);
|
|
4461
|
+
for (const [otherWs, otherClient] of clients) {
|
|
4462
|
+
if (otherWs === ws || otherClient.clientId !== payload.client.clientId) continue;
|
|
4463
|
+
const otherTokenKey = otherClient.authToken === void 0 ? void 0 : hqTokenKey3(otherClient.authToken);
|
|
4464
|
+
if (otherTokenKey !== helloTokenKey && otherWs.readyState === WebSocket3.OPEN) {
|
|
4465
|
+
ws.close(4003, "clientId is held by another credential");
|
|
4466
|
+
return;
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4342
4469
|
for (const [otherWs, otherClient] of clients) {
|
|
4343
4470
|
const sameClientId = otherClient.clientId === payload.client.clientId;
|
|
4344
4471
|
const samePublisher = otherClient.projectId === payload.project.projectId && otherClient.kind === payload.client.kind && otherClient.pid !== void 0 && payload.client.pid !== void 0 && otherClient.pid === payload.client.pid && (otherClient.machineId || otherClient.project.machineId) === (payload.client.machineId || payload.project.machineId) && isSessionSurface === otherClient.capabilities.includes("session.summary");
|
|
4345
|
-
|
|
4472
|
+
const sameCredential = (otherClient.authToken === void 0 ? void 0 : hqTokenKey3(otherClient.authToken)) === helloTokenKey;
|
|
4473
|
+
if (otherWs !== ws && sameCredential && (sameClientId || samePublisher && otherClient.sessions.size === 0)) {
|
|
4346
4474
|
if (otherClient.isLeader) {
|
|
4347
4475
|
if (sameClientId) inheritedLeader = true;
|
|
4348
4476
|
else supersededLeaders.push(otherClient);
|
|
4349
4477
|
}
|
|
4478
|
+
if (sameClientId) inheritedCommands.push(...undeliveredCommands(otherClient, auditLog));
|
|
4479
|
+
else failUndeliveredCommands(otherClient, browsers, auditLog);
|
|
4350
4480
|
clients.delete(otherWs);
|
|
4351
4481
|
otherWs.close(4001, "superseded by a newer HQ connection");
|
|
4352
4482
|
}
|
|
@@ -4371,7 +4501,7 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
|
|
|
4371
4501
|
sessions: /* @__PURE__ */ new Map(),
|
|
4372
4502
|
fleets: /* @__PURE__ */ new Map(),
|
|
4373
4503
|
mcpSnapshots: /* @__PURE__ */ new Map(),
|
|
4374
|
-
commandQueue:
|
|
4504
|
+
commandQueue: inheritedCommands,
|
|
4375
4505
|
isLeader: inheritedLeader || computeIsLeader(clients, payload.project.projectId, acceptedCapabilities)
|
|
4376
4506
|
};
|
|
4377
4507
|
clients.set(ws, client);
|
|
@@ -4439,7 +4569,7 @@ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster
|
|
|
4439
4569
|
}
|
|
4440
4570
|
if (toSend.length > 0) {
|
|
4441
4571
|
const batch = JSON.stringify({ type: "hq.command_batch", commands: toSend });
|
|
4442
|
-
if (ws.readyState ===
|
|
4572
|
+
if (ws.readyState === WebSocket3.OPEN) ws.send(batch);
|
|
4443
4573
|
const dispatchedAt = Date.now();
|
|
4444
4574
|
for (const cmd of toSend) {
|
|
4445
4575
|
auditLog?.update(cmd.commandId, { status: "delivered", dispatchedAt });
|
|
@@ -4565,11 +4695,16 @@ function handleHqUpgrade(req, socket, head, deps) {
|
|
|
4565
4695
|
supplied = "";
|
|
4566
4696
|
}
|
|
4567
4697
|
}
|
|
4568
|
-
const
|
|
4569
|
-
const
|
|
4698
|
+
const matchedTokenKey = timingSafeTokenMatch(tokenSet, supplied);
|
|
4699
|
+
const tokenValid = matchedTokenKey !== void 0;
|
|
4700
|
+
if (tokenValid && pathname === "/ws/browser") {
|
|
4701
|
+
req.hqBrowserTokenKey = matchedTokenKey;
|
|
4702
|
+
}
|
|
4703
|
+
const cookieSecret = deps.mutableAuth.cookieSecret;
|
|
4704
|
+
const cookieValid = pathname === "/ws/browser" && cookieSecret !== void 0 && (() => {
|
|
4570
4705
|
const raw = readHqSessionCookie(req.headers.cookie);
|
|
4571
4706
|
if (!raw) return false;
|
|
4572
|
-
const sessionId = parseHqSessionCookie2(raw,
|
|
4707
|
+
const sessionId = parseHqSessionCookie2(raw, cookieSecret);
|
|
4573
4708
|
if (sessionId === void 0) return false;
|
|
4574
4709
|
const session = deps.sessions.get(sessionId);
|
|
4575
4710
|
if (!session || Date.now() - session.createdAt >= HQ_SESSION_MAX_AGE_MS)
|
|
@@ -4581,7 +4716,7 @@ function handleHqUpgrade(req, socket, head, deps) {
|
|
|
4581
4716
|
if (session.pending2fa) return false;
|
|
4582
4717
|
if (session.kind === "token" && session.tokenId !== void 0) {
|
|
4583
4718
|
const stillAuthorized = [...deps.mutableAuth.browserTokenObjs.values()].some(
|
|
4584
|
-
(obj) => obj.id === session.tokenId
|
|
4719
|
+
(obj) => obj.id === session.tokenId && !isTokenExpired5(obj)
|
|
4585
4720
|
);
|
|
4586
4721
|
if (!stillAuthorized) return false;
|
|
4587
4722
|
}
|
|
@@ -4611,6 +4746,13 @@ function handleHqConnection(ws, req, pathname, deps) {
|
|
|
4611
4746
|
if (sessionId !== void 0) {
|
|
4612
4747
|
deps.browserSocketSessions.set(ws, sessionId);
|
|
4613
4748
|
ws.once("close", () => deps.browserSocketSessions.delete(ws));
|
|
4749
|
+
} else {
|
|
4750
|
+
const tokenKey = req.hqBrowserTokenKey;
|
|
4751
|
+
const tokenSockets = deps.browserSocketTokenKeys;
|
|
4752
|
+
if (tokenKey !== void 0 && tokenSockets !== void 0) {
|
|
4753
|
+
tokenSockets.set(ws, tokenKey);
|
|
4754
|
+
ws.once("close", () => tokenSockets.delete(ws));
|
|
4755
|
+
}
|
|
4614
4756
|
}
|
|
4615
4757
|
handleBrowser(ws, deps.snapshotBroadcaster, deps.browsers, deps.eventLog);
|
|
4616
4758
|
} else {
|
|
@@ -4683,7 +4825,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4683
4825
|
let revokeBrowserSessions;
|
|
4684
4826
|
const authState = createHqAuthState(authFile, dataDir, {
|
|
4685
4827
|
onApplied: (live) => reassessExposureFloor(live),
|
|
4686
|
-
onTokensRevoked: (keys) => revokeBrowserSessions?.(keys),
|
|
4828
|
+
onTokensRevoked: (keys, ids) => revokeBrowserSessions?.(keys, ids),
|
|
4687
4829
|
requireBrowserAuth: options.requireBrowserAuth
|
|
4688
4830
|
});
|
|
4689
4831
|
const { mutableAuth } = authState;
|
|
@@ -4729,15 +4871,18 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4729
4871
|
const clients = /* @__PURE__ */ new Map();
|
|
4730
4872
|
const clientSocketTokens = /* @__PURE__ */ new Map();
|
|
4731
4873
|
const browserSocketSessions = /* @__PURE__ */ new Map();
|
|
4874
|
+
const browserSocketTokenKeys = /* @__PURE__ */ new Map();
|
|
4732
4875
|
const browsers = /* @__PURE__ */ new Set();
|
|
4733
4876
|
const sessions = /* @__PURE__ */ new Map();
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4877
|
+
const socketCredentials = createHqSocketCredentialEnforcer({
|
|
4878
|
+
mutableAuth,
|
|
4879
|
+
sessions,
|
|
4880
|
+
browsers,
|
|
4881
|
+
browserSocketSessions,
|
|
4882
|
+
browserSocketTokenKeys,
|
|
4883
|
+
clientSocketTokens
|
|
4884
|
+
});
|
|
4885
|
+
revokeBrowserSessions = (keys, ids) => socketCredentials.announceRevokedTokens(keys, ids);
|
|
4741
4886
|
const eventLog = [];
|
|
4742
4887
|
const transcripts = /* @__PURE__ */ new Map();
|
|
4743
4888
|
const agentMessages = /* @__PURE__ */ new Map();
|
|
@@ -4759,6 +4904,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4759
4904
|
for (const [id, session] of sessions) {
|
|
4760
4905
|
if (session.createdAt < maxAgeCutoff) {
|
|
4761
4906
|
sessions.delete(id);
|
|
4907
|
+
socketCredentials.closeSessionSockets(id, "Browser session expired");
|
|
4762
4908
|
continue;
|
|
4763
4909
|
}
|
|
4764
4910
|
if (session.pending2fa) {
|
|
@@ -4814,7 +4960,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4814
4960
|
const msg = toAlertMessage(alert);
|
|
4815
4961
|
const data = JSON.stringify(msg);
|
|
4816
4962
|
for (const ws of browsers) {
|
|
4817
|
-
if (ws.readyState ===
|
|
4963
|
+
if (ws.readyState === WebSocket4.OPEN) ws.send(data);
|
|
4818
4964
|
}
|
|
4819
4965
|
},
|
|
4820
4966
|
onPersist: (alert) => persistence.alertLog.append(alert)
|
|
@@ -4853,6 +4999,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4853
4999
|
const clientTtlMs = options.clientTtlMs ?? CLIENT_TTL_MS;
|
|
4854
5000
|
const sessionSnapshotTtlMs = options.sessionSnapshotTtlMs ?? SESSION_SNAPSHOT_TTL_MS;
|
|
4855
5001
|
const cleanupTimer = setInterval(() => {
|
|
5002
|
+
socketCredentials.sweepExpiredSocketCredentials();
|
|
4856
5003
|
const cutoff = Date.now() - clientTtlMs;
|
|
4857
5004
|
const sessionCutoff = Date.now() - sessionSnapshotTtlMs;
|
|
4858
5005
|
let changed = false;
|
|
@@ -4861,6 +5008,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4861
5008
|
const lostClient = client;
|
|
4862
5009
|
ws.terminate();
|
|
4863
5010
|
clients.delete(ws);
|
|
5011
|
+
failUndeliveredCommands(lostClient, browsers, auditLog);
|
|
4864
5012
|
detectLeaderLoss(lostClient, clients, browsers, "heartbeat-timeout", {
|
|
4865
5013
|
eventLog,
|
|
4866
5014
|
persistence
|
|
@@ -4887,7 +5035,8 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4887
5035
|
const lostClient = client;
|
|
4888
5036
|
ws.terminate();
|
|
4889
5037
|
clients.delete(ws);
|
|
4890
|
-
|
|
5038
|
+
failUndeliveredCommands(lostClient, browsers, auditLog);
|
|
5039
|
+
detectLeaderLoss(lostClient, clients, browsers, "crash", {
|
|
4891
5040
|
eventLog,
|
|
4892
5041
|
persistence
|
|
4893
5042
|
});
|
|
@@ -4933,7 +5082,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4933
5082
|
const closeSocketsOfRemovedSessions = (before) => {
|
|
4934
5083
|
for (const [browser, sessionId] of browserSocketSessions) {
|
|
4935
5084
|
if (!before.has(sessionId) || sessions.has(sessionId)) continue;
|
|
4936
|
-
if (browser.readyState ===
|
|
5085
|
+
if (browser.readyState === WebSocket4.OPEN) {
|
|
4937
5086
|
browser.close(1008, "Browser session revoked");
|
|
4938
5087
|
}
|
|
4939
5088
|
}
|
|
@@ -4951,7 +5100,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4951
5100
|
serverTime: (/* @__PURE__ */ new Date()).toISOString()
|
|
4952
5101
|
});
|
|
4953
5102
|
for (const browser of browsers) {
|
|
4954
|
-
if (browser.readyState ===
|
|
5103
|
+
if (browser.readyState === WebSocket4.OPEN) browser.send(heartbeat);
|
|
4955
5104
|
}
|
|
4956
5105
|
}, options.browserHeartbeatIntervalMs ?? BROWSER_HEARTBEAT_INTERVAL_MS);
|
|
4957
5106
|
browserHeartbeatTimer.unref?.();
|
|
@@ -4967,6 +5116,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
4967
5116
|
clients,
|
|
4968
5117
|
clientSocketTokens,
|
|
4969
5118
|
browserSocketSessions,
|
|
5119
|
+
browserSocketTokenKeys,
|
|
4970
5120
|
browsers,
|
|
4971
5121
|
eventLog,
|
|
4972
5122
|
transcripts,
|
|
@@ -5010,11 +5160,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
5010
5160
|
}
|
|
5011
5161
|
}
|
|
5012
5162
|
}
|
|
5013
|
-
|
|
5014
|
-
for (const [clientSocket, authToken] of clientSocketTokens) {
|
|
5015
|
-
const stillAuthorized = authToken === void 0 ? !clientAuthRequired : !isTokenExpired4(authToken) && mutableAuth.clientTokenObjs.has(hqTokenKey2(authToken));
|
|
5016
|
-
if (!stillAuthorized) clientSocket.close(1008, "Client authentication revoked");
|
|
5017
|
-
}
|
|
5163
|
+
socketCredentials.closeUnauthorizedClientSockets();
|
|
5018
5164
|
if ((options.requireBrowserAuth || mutableAuth.requireAuthFloor) && mutableAuth.browserTokens.size === 0 && mutableAuth.passwordHash === void 0) {
|
|
5019
5165
|
sessions.clear();
|
|
5020
5166
|
for (const browser of browsers) browser.close(1008, "Browser authentication removed");
|
|
@@ -5070,7 +5216,7 @@ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth)
|
|
|
5070
5216
|
const hqUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`;
|
|
5071
5217
|
await writeHqRuntimeMarker(dataDir, hqUrl).catch(() => {
|
|
5072
5218
|
});
|
|
5073
|
-
const browserTokenObj = (firstRunAuth.browserToken !== void 0 ? mutableAuth.browserTokenObjs.get(
|
|
5219
|
+
const browserTokenObj = (firstRunAuth.browserToken !== void 0 ? mutableAuth.browserTokenObjs.get(hqTokenKey4(firstRunAuth.browserToken)) : void 0) ?? mutableAuth.browserTokenObjs.values().next().value;
|
|
5074
5220
|
let browserUrl;
|
|
5075
5221
|
if (browserTokenObj) {
|
|
5076
5222
|
const code = bootstrapStore.issue({
|
|
@@ -5150,4 +5296,4 @@ export {
|
|
|
5150
5296
|
startHqServer,
|
|
5151
5297
|
HqInsecureExposureError2 as HqInsecureExposureError
|
|
5152
5298
|
};
|
|
5153
|
-
//# sourceMappingURL=chunk-
|
|
5299
|
+
//# sourceMappingURL=chunk-ENRYW73H.js.map
|