@wrongstack/cli 0.296.2 → 0.296.3
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/boot/dispatch-webui.d.ts +1 -0
- package/dist/boot/dispatch-webui.d.ts.map +1 -1
- package/dist/boot/tui-session-resume.d.ts.map +1 -1
- package/dist/{chunk-IN4GXGYW.js → chunk-BQCAJ7FI.js} +2 -2
- package/dist/{chunk-IN4GXGYW.js.map → chunk-BQCAJ7FI.js.map} +2 -2
- package/dist/{chunk-X5DQTPXT.js → chunk-OI7GWJDS.js} +1048 -804
- package/dist/chunk-OI7GWJDS.js.map +7 -0
- package/dist/{cli-main-6AU6E4XZ.js → cli-main-RTY35OPD.js} +196 -54
- package/dist/cli-main-RTY35OPD.js.map +7 -0
- package/dist/cli-main.d.ts.map +1 -1
- package/dist/execute-deps.d.ts +1 -0
- package/dist/execute-deps.d.ts.map +1 -1
- package/dist/{execution-V26SE52F.js → execution-O7I5DYE6.js} +34 -8
- package/dist/execution-O7I5DYE6.js.map +7 -0
- package/dist/execution-storage-observability.d.ts.map +1 -1
- package/dist/execution.d.ts.map +1 -1
- package/dist/fleet/host-subagent-factory.d.ts.map +1 -1
- package/dist/{hq-BECGICEZ.js → hq-JUYKUNJX.js} +2 -2
- package/dist/hq-server/auth.d.ts +15 -4
- package/dist/hq-server/auth.d.ts.map +1 -1
- package/dist/hq-server/routes/auth-handlers.d.ts +6 -13
- package/dist/hq-server/routes/auth-handlers.d.ts.map +1 -1
- package/dist/hq-server/routes.d.ts +6 -4
- package/dist/hq-server/routes.d.ts.map +1 -1
- package/dist/hq-server/snapshot.d.ts +44 -0
- package/dist/hq-server/snapshot.d.ts.map +1 -1
- package/dist/hq-server/types.d.ts +22 -4
- package/dist/hq-server/types.d.ts.map +1 -1
- package/dist/hq-server/utils.d.ts +8 -0
- package/dist/hq-server/utils.d.ts.map +1 -1
- package/dist/hq-server/ws.d.ts +1 -1
- package/dist/hq-server/ws.d.ts.map +1 -1
- package/dist/{hq-server-FJOVOERO.js → hq-server-6AQ3BXZA.js} +2 -2
- package/dist/hq-server.d.ts +3 -3
- package/dist/hq-server.d.ts.map +1 -1
- package/dist/{hq-tunnel-YZLRONPV.js → hq-tunnel-RFKGC4HI.js} +8 -5
- package/dist/{hq-tunnel-YZLRONPV.js.map → hq-tunnel-RFKGC4HI.js.map} +2 -2
- package/dist/hq-tunnel.d.ts +1 -1
- package/dist/hq-tunnel.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/{sdd-runtime-IKPROVV2.js → sdd-runtime-JG4EEKL6.js} +2 -2
- package/dist/services/sdd/task-manager.d.ts.map +1 -1
- package/dist/slash-commands/settings.d.ts.map +1 -1
- package/dist/{webui-server-G3TISUIY.js → webui-server-ZF5KVGC5.js} +3 -2
- package/dist/webui-server-ZF5KVGC5.js.map +7 -0
- package/dist/webui-server-options.d.ts +2 -0
- package/dist/webui-server-options.d.ts.map +1 -1
- package/dist/webui-server.d.ts.map +1 -1
- package/dist/wiring/sage.d.ts.map +1 -1
- package/dist/wiring/session.d.ts +3 -1
- package/dist/wiring/session.d.ts.map +1 -1
- package/package.json +22 -22
- package/dist/chunk-X5DQTPXT.js.map +0 -7
- package/dist/cli-main-6AU6E4XZ.js.map +0 -7
- package/dist/execution-V26SE52F.js.map +0 -7
- package/dist/webui-server-G3TISUIY.js.map +0 -7
- /package/dist/{hq-BECGICEZ.js.map → hq-JUYKUNJX.js.map} +0 -0
- /package/dist/{hq-server-FJOVOERO.js.map → hq-server-6AQ3BXZA.js.map} +0 -0
- /package/dist/{sdd-runtime-IKPROVV2.js.map → sdd-runtime-JG4EEKL6.js.map} +0 -0
|
@@ -7,17 +7,26 @@ import { randomUUID as randomUUID4 } from "node:crypto";
|
|
|
7
7
|
import * as fs4 from "node:fs/promises";
|
|
8
8
|
import * as http from "node:http";
|
|
9
9
|
import * as path6 from "node:path";
|
|
10
|
-
import { WebSocket as WebSocket3, WebSocketServer } from "ws";
|
|
11
|
-
import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
|
|
12
|
-
import { createHqPersistence, toAlertMessage, watchHqAuthFile } from "@wrongstack/core/hq";
|
|
13
|
-
import { createMailboxHttpRouter, MAILBOX_HTTP_DEFAULT_MAX_AGE_MS } from "@wrongstack/core/coordination";
|
|
14
|
-
import { HqAlertEngine, HqCommandAuditLog } from "@wrongstack/core/hq";
|
|
15
10
|
import {
|
|
11
|
+
createMailboxHttpRouter,
|
|
16
12
|
createProjectMailbox,
|
|
17
13
|
getSharedProjectMailbox,
|
|
14
|
+
MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
|
|
18
15
|
MailboxEventEmitter,
|
|
19
16
|
MailboxHttpRateLimiter
|
|
20
17
|
} from "@wrongstack/core/coordination";
|
|
18
|
+
import {
|
|
19
|
+
createHqPersistence,
|
|
20
|
+
HqAlertEngine,
|
|
21
|
+
HqCommandAuditLog,
|
|
22
|
+
HqBootstrapCodeStore,
|
|
23
|
+
mintHqCookieSecret as mintHqCookieSecret2,
|
|
24
|
+
mutateHqAuthFile as mutateHqAuthFile2,
|
|
25
|
+
toAlertMessage,
|
|
26
|
+
watchHqAuthFile
|
|
27
|
+
} from "@wrongstack/core/hq";
|
|
28
|
+
import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
|
|
29
|
+
import { WebSocket as WebSocket3, WebSocketServer } from "ws";
|
|
21
30
|
|
|
22
31
|
// src/hq-recovery-html.ts
|
|
23
32
|
var HQ_RECOVERY_HTML = `<!doctype html>
|
|
@@ -172,7 +181,10 @@ function clearHqSessionCookie(res, secure) {
|
|
|
172
181
|
res.setHeader("Set-Cookie", parts.join("; "));
|
|
173
182
|
}
|
|
174
183
|
function isTokenAuth(auth) {
|
|
175
|
-
return auth !== void 0 && auth
|
|
184
|
+
return auth !== void 0 && auth.kind === "token";
|
|
185
|
+
}
|
|
186
|
+
function isCookieAuth(auth) {
|
|
187
|
+
return auth !== void 0 && auth.kind === "cookie";
|
|
176
188
|
}
|
|
177
189
|
function extractBrowserToken(req, url) {
|
|
178
190
|
const queryToken = url.searchParams.get("token");
|
|
@@ -217,7 +229,7 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
|
|
|
217
229
|
return ctx;
|
|
218
230
|
}
|
|
219
231
|
}
|
|
220
|
-
if (mutableAuth.
|
|
232
|
+
if (mutableAuth.cookieSecret) {
|
|
221
233
|
const cookies = parseCookieHeader(req.headers.cookie);
|
|
222
234
|
const raw = cookies[HQ_SESSION_COOKIE];
|
|
223
235
|
if (raw) {
|
|
@@ -225,7 +237,19 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
|
|
|
225
237
|
if (sessionId) {
|
|
226
238
|
const session = sessions.get(sessionId);
|
|
227
239
|
if (session && Date.now() - session.createdAt < HQ_SESSION_MAX_AGE_MS) {
|
|
228
|
-
|
|
240
|
+
if (session.kind === "token" && session.tokenId !== void 0) {
|
|
241
|
+
const liveToken = [...mutableAuth.browserTokenObjs.values()].find(
|
|
242
|
+
(obj) => obj.id === session.tokenId
|
|
243
|
+
);
|
|
244
|
+
if (!liveToken) {
|
|
245
|
+
sessions.delete(sessionId);
|
|
246
|
+
return void 0;
|
|
247
|
+
}
|
|
248
|
+
const ctx = { kind: "cookie", tokenId: session.tokenId };
|
|
249
|
+
if (liveToken.capabilities !== void 0) ctx.capabilities = liveToken.capabilities;
|
|
250
|
+
return ctx;
|
|
251
|
+
}
|
|
252
|
+
return { kind: "cookie" };
|
|
229
253
|
}
|
|
230
254
|
if (session) sessions.delete(sessionId);
|
|
231
255
|
}
|
|
@@ -234,14 +258,260 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
|
|
|
234
258
|
return void 0;
|
|
235
259
|
}
|
|
236
260
|
|
|
261
|
+
// src/hq-server/auth-state.ts
|
|
262
|
+
import { DEFAULT_HQ_REDACTION_POLICY, hqAuthContentHash, isTokenExpired, logHqAuthAudit } from "@wrongstack/core/hq";
|
|
263
|
+
var TOKEN_EXPIRY_WARNING_MS = 24 * 60 * 60 * 1e3;
|
|
264
|
+
function liveTokens(list) {
|
|
265
|
+
return (list ?? []).filter((token) => !isTokenExpired(token));
|
|
266
|
+
}
|
|
267
|
+
function createHqAuthState(authFile, dataDir) {
|
|
268
|
+
let rawBrowserTokens = authFile.browserTokens ?? [];
|
|
269
|
+
let rawClientTokens = authFile.clientTokens ?? [];
|
|
270
|
+
const mutableAuth = {
|
|
271
|
+
operatorPolicy: { ...DEFAULT_HQ_REDACTION_POLICY, ...authFile.redactionPolicy ?? {} },
|
|
272
|
+
operatorPolicyOverride: authFile.redactionPolicy,
|
|
273
|
+
browserTokens: new Set(liveTokens(authFile.browserTokens).map(({ token }) => token)),
|
|
274
|
+
clientTokens: new Set(liveTokens(authFile.clientTokens).map(({ token }) => token)),
|
|
275
|
+
browserTokenObjs: browserTokenMap(authFile.browserTokens),
|
|
276
|
+
clientTokenObjs: new Map(liveTokens(authFile.clientTokens).map((token) => [token.token, token])),
|
|
277
|
+
passwordHash: authFile.passwordHash,
|
|
278
|
+
cookieSecret: authFile.cookieSecret,
|
|
279
|
+
alertRules: authFile.alertRules
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
mutableAuth,
|
|
283
|
+
tokenStats: () => {
|
|
284
|
+
const now = Date.now();
|
|
285
|
+
const all = [...rawBrowserTokens, ...rawClientTokens];
|
|
286
|
+
const live = all.filter((token) => !isTokenExpired(token, now));
|
|
287
|
+
return {
|
|
288
|
+
browserTotal: mutableAuth.browserTokens.size,
|
|
289
|
+
clientTotal: mutableAuth.clientTokens.size,
|
|
290
|
+
expired: all.length - live.length,
|
|
291
|
+
expiringSoon: live.filter((token) => {
|
|
292
|
+
if (token.expiresAt === void 0) return false;
|
|
293
|
+
const expiry = Date.parse(token.expiresAt);
|
|
294
|
+
return Number.isFinite(expiry) && expiry - now <= TOKEN_EXPIRY_WARNING_MS;
|
|
295
|
+
}).length
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
apply: (next) => {
|
|
299
|
+
const newBrowser = next.browserTokens ?? [];
|
|
300
|
+
const newClient = next.clientTokens ?? [];
|
|
301
|
+
auditPrunedTokens("browser", rawBrowserTokens, newBrowser, next, dataDir);
|
|
302
|
+
auditPrunedTokens("client", rawClientTokens, newClient, next, dataDir);
|
|
303
|
+
rawBrowserTokens = newBrowser;
|
|
304
|
+
rawClientTokens = newClient;
|
|
305
|
+
mutableAuth.operatorPolicy = {
|
|
306
|
+
...DEFAULT_HQ_REDACTION_POLICY,
|
|
307
|
+
...next.redactionPolicy ?? {}
|
|
308
|
+
};
|
|
309
|
+
mutableAuth.operatorPolicyOverride = next.redactionPolicy;
|
|
310
|
+
mutableAuth.browserTokens = new Set(liveTokens(next.browserTokens).map(({ token }) => token));
|
|
311
|
+
mutableAuth.clientTokens = new Set(liveTokens(next.clientTokens).map(({ token }) => token));
|
|
312
|
+
mutableAuth.browserTokenObjs = browserTokenMap(next.browserTokens);
|
|
313
|
+
mutableAuth.clientTokenObjs = new Map(
|
|
314
|
+
liveTokens(next.clientTokens).map((token) => [token.token, token])
|
|
315
|
+
);
|
|
316
|
+
mutableAuth.passwordHash = next.passwordHash;
|
|
317
|
+
mutableAuth.cookieSecret = next.cookieSecret;
|
|
318
|
+
mutableAuth.alertRules = next.alertRules;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function browserTokenMap(tokens) {
|
|
323
|
+
return new Map(
|
|
324
|
+
liveTokens(tokens).map((token) => [
|
|
325
|
+
token.token,
|
|
326
|
+
{
|
|
327
|
+
id: token.id,
|
|
328
|
+
...token.capabilities !== void 0 ? { capabilities: token.capabilities } : {},
|
|
329
|
+
...token.expiresAt !== void 0 ? { expiresAt: token.expiresAt } : {}
|
|
330
|
+
}
|
|
331
|
+
])
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
function auditPrunedTokens(scope, previous, next, authFile, dataDir) {
|
|
335
|
+
const previouslyLive = new Set(previous.filter((token) => !isTokenExpired(token)).map(({ id }) => id));
|
|
336
|
+
const prunedCount = next.filter((token) => isTokenExpired(token) && previouslyLive.has(token.id)).length;
|
|
337
|
+
if (prunedCount === 0) return;
|
|
338
|
+
const contentHash = hqAuthContentHash(authFile);
|
|
339
|
+
logHqAuthAudit(dataDir, {
|
|
340
|
+
kind: "expired-prune",
|
|
341
|
+
scope,
|
|
342
|
+
tokenId: "(aggregate)",
|
|
343
|
+
prunedCount,
|
|
344
|
+
...contentHash !== void 0 ? { contentHash } : {}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/hq-server/preflight.ts
|
|
349
|
+
import { assessHqExposure, ensureHqFirstRunAuthFile, resolveHqDataDir } from "@wrongstack/core/hq";
|
|
350
|
+
import { HqInsecureExposureError } from "@wrongstack/core/hq";
|
|
351
|
+
async function prepareHqServerStart(options, defaults) {
|
|
352
|
+
const host = options.host ?? defaults.host;
|
|
353
|
+
const port = options.port ?? defaults.port;
|
|
354
|
+
const dataDir = resolveHqDataDir(options.dataDir);
|
|
355
|
+
const firstRunAuth = await ensureHqFirstRunAuthFile(dataDir, {
|
|
356
|
+
warn: (message) => console.warn(
|
|
357
|
+
JSON.stringify({
|
|
358
|
+
level: "warn",
|
|
359
|
+
event: "hq.auth_load_failed",
|
|
360
|
+
message,
|
|
361
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
362
|
+
})
|
|
363
|
+
),
|
|
364
|
+
actor: resolveAuditActor(),
|
|
365
|
+
...options.password !== void 0 ? { password: options.password } : {},
|
|
366
|
+
...options.tokenTtlMs !== void 0 ? { tokenTtlMs: options.tokenTtlMs } : {}
|
|
367
|
+
});
|
|
368
|
+
const { authFile } = firstRunAuth;
|
|
369
|
+
if (options.requireBrowserAuth && (authFile.browserTokens ?? []).length === 0 && authFile.passwordHash === void 0) {
|
|
370
|
+
throw new HqInsecureExposureError(
|
|
371
|
+
"HQ public relay requires browser authentication. Set --password or create a browser token first."
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const exposure = assessHqExposure({
|
|
375
|
+
host,
|
|
376
|
+
hasBrowserTokens: (authFile.browserTokens ?? []).length > 0,
|
|
377
|
+
hasPassword: authFile.passwordHash !== void 0,
|
|
378
|
+
allowInsecure: options.allowInsecureOpen
|
|
379
|
+
});
|
|
380
|
+
if (exposure.kind === "refuse") throw new HqInsecureExposureError(exposure.message);
|
|
381
|
+
if (exposure.kind === "warn") {
|
|
382
|
+
console.warn(
|
|
383
|
+
JSON.stringify({
|
|
384
|
+
level: "warn",
|
|
385
|
+
event: "hq.insecure_exposure",
|
|
386
|
+
message: exposure.message,
|
|
387
|
+
host,
|
|
388
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return { host, port, dataDir, firstRunAuth };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/hq-server/routes.ts
|
|
396
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
397
|
+
import * as path4 from "node:path";
|
|
398
|
+
import { tokenHasCapability, validateHqCommand } from "@wrongstack/core/hq";
|
|
399
|
+
import {
|
|
400
|
+
resolveProjectDir
|
|
401
|
+
} from "@wrongstack/core/coordination";
|
|
402
|
+
|
|
403
|
+
// src/hq-static-serve.ts
|
|
404
|
+
import * as fs from "node:fs";
|
|
405
|
+
import { createRequire } from "node:module";
|
|
406
|
+
import * as path from "node:path";
|
|
407
|
+
var requireFromHere = createRequire(import.meta.url);
|
|
408
|
+
var MIME = {
|
|
409
|
+
".html": "text/html; charset=utf-8",
|
|
410
|
+
".js": "application/javascript; charset=utf-8",
|
|
411
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
412
|
+
".css": "text/css; charset=utf-8",
|
|
413
|
+
".json": "application/json; charset=utf-8",
|
|
414
|
+
".svg": "image/svg+xml",
|
|
415
|
+
".png": "image/png",
|
|
416
|
+
".jpg": "image/jpeg",
|
|
417
|
+
".woff": "font/woff",
|
|
418
|
+
".woff2": "font/woff2",
|
|
419
|
+
".ico": "image/x-icon"
|
|
420
|
+
};
|
|
421
|
+
var cachedDistDir;
|
|
422
|
+
function resolveHqDistDir() {
|
|
423
|
+
if (cachedDistDir !== void 0) return cachedDistDir;
|
|
424
|
+
try {
|
|
425
|
+
const pkgJson = requireFromHere.resolve("@wrongstack/webui-hq/package.json");
|
|
426
|
+
const dist = path.join(path.dirname(pkgJson), "dist");
|
|
427
|
+
if (fs.existsSync(path.join(dist, "index.html"))) {
|
|
428
|
+
cachedDistDir = dist;
|
|
429
|
+
return dist;
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
let dir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
|
|
435
|
+
for (let i = 0; i < 8; i++) {
|
|
436
|
+
const candidate = path.join(dir, "node_modules", "@wrongstack", "webui-hq", "dist");
|
|
437
|
+
if (fs.existsSync(path.join(candidate, "index.html"))) {
|
|
438
|
+
cachedDistDir = candidate;
|
|
439
|
+
return candidate;
|
|
440
|
+
}
|
|
441
|
+
const parent = path.dirname(dir);
|
|
442
|
+
if (parent === dir) break;
|
|
443
|
+
dir = parent;
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
}
|
|
447
|
+
cachedDistDir = null;
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
function isInsideDist(filePath, distDir) {
|
|
451
|
+
const rel = path.relative(distDir, filePath);
|
|
452
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
453
|
+
}
|
|
454
|
+
function isMissingPath(error) {
|
|
455
|
+
const code = error.code;
|
|
456
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
457
|
+
}
|
|
458
|
+
async function readRegularFile(filePath) {
|
|
459
|
+
try {
|
|
460
|
+
const info = await fs.promises.stat(filePath);
|
|
461
|
+
if (!info.isFile()) return null;
|
|
462
|
+
return await fs.promises.readFile(filePath);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (isMissingPath(error)) return null;
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function serveHqStatic(_req, res, urlPath, distDir) {
|
|
469
|
+
const relPath = urlPath === "/" || urlPath === "" ? "/index.html" : urlPath;
|
|
470
|
+
let decodedPath;
|
|
471
|
+
try {
|
|
472
|
+
decodedPath = decodeURIComponent(relPath);
|
|
473
|
+
} catch {
|
|
474
|
+
return { handled: false };
|
|
475
|
+
}
|
|
476
|
+
const filePath = path.join(distDir, decodedPath);
|
|
477
|
+
if (!isInsideDist(filePath, distDir)) {
|
|
478
|
+
return { handled: false };
|
|
479
|
+
}
|
|
480
|
+
const data = await readRegularFile(filePath);
|
|
481
|
+
if (data === null) {
|
|
482
|
+
const indexPath = path.join(distDir, "index.html");
|
|
483
|
+
const html = await readRegularFile(indexPath);
|
|
484
|
+
if (html !== null) {
|
|
485
|
+
res.writeHead(200, {
|
|
486
|
+
"Content-Type": MIME[".html"],
|
|
487
|
+
"Cache-Control": "no-cache",
|
|
488
|
+
"Content-Length": html.byteLength
|
|
489
|
+
});
|
|
490
|
+
res.end(html);
|
|
491
|
+
return { handled: true };
|
|
492
|
+
}
|
|
493
|
+
return { handled: false };
|
|
494
|
+
}
|
|
495
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
496
|
+
const mime = MIME[ext] ?? "application/octet-stream";
|
|
497
|
+
const cacheControl = decodedPath.startsWith("/assets/") ? "public, max-age=31536000, immutable" : ext === ".html" ? "no-cache" : "public, max-age=3600";
|
|
498
|
+
res.writeHead(200, {
|
|
499
|
+
"Content-Type": mime,
|
|
500
|
+
"Cache-Control": cacheControl,
|
|
501
|
+
"Content-Length": data.byteLength
|
|
502
|
+
});
|
|
503
|
+
res.end(data);
|
|
504
|
+
return { handled: true };
|
|
505
|
+
}
|
|
506
|
+
|
|
237
507
|
// src/hq-server/snapshot.ts
|
|
238
508
|
import { WebSocket } from "ws";
|
|
239
509
|
|
|
240
510
|
// src/hq-server/utils.ts
|
|
241
|
-
import * as
|
|
511
|
+
import * as fs2 from "node:fs/promises";
|
|
242
512
|
import * as os from "node:os";
|
|
243
|
-
import * as
|
|
244
|
-
import { resolveHqDataDir } from "@wrongstack/core/hq";
|
|
513
|
+
import * as path2 from "node:path";
|
|
514
|
+
import { resolveHqDataDir as resolveHqDataDir2 } from "@wrongstack/core/hq";
|
|
245
515
|
var TRANSCRIPT_RING_MAX = 4e3;
|
|
246
516
|
var MAX_TRANSCRIPT_SESSIONS = 400;
|
|
247
517
|
var MAX_AGENT_RINGS = 800;
|
|
@@ -270,14 +540,14 @@ async function readLocalSubagentTranscript(sessionId, subagentId) {
|
|
|
270
540
|
try {
|
|
271
541
|
const { SessionRegistry } = await import("@wrongstack/core/storage");
|
|
272
542
|
const { resolveWstackPaths, sessionScopedPath } = await import("@wrongstack/core/utils");
|
|
273
|
-
const globalRoot =
|
|
543
|
+
const globalRoot = path2.dirname(resolveHqDataDir2());
|
|
274
544
|
const registry = new SessionRegistry(globalRoot);
|
|
275
545
|
const entry = await registry.get(sessionId).catch(() => null);
|
|
276
546
|
if (!entry) return null;
|
|
277
547
|
const paths = resolveWstackPaths({ projectRoot: entry.projectRoot, globalRoot });
|
|
278
548
|
const sessionDir = sessionScopedPath(paths.projectSessions, sessionId, "");
|
|
279
|
-
const file =
|
|
280
|
-
const raw = await
|
|
549
|
+
const file = path2.join(sessionDir, "subagents", "transcripts", subagentId, "transcript.jsonl");
|
|
550
|
+
const raw = await fs2.readFile(file, "utf8").catch(() => null);
|
|
281
551
|
if (raw === null) return null;
|
|
282
552
|
const out = [];
|
|
283
553
|
for (const line of raw.split("\n")) {
|
|
@@ -339,13 +609,18 @@ function buildHttpUrl(host, port, token) {
|
|
|
339
609
|
if (token) url.searchParams.set("token", token);
|
|
340
610
|
return url.toString();
|
|
341
611
|
}
|
|
612
|
+
function buildBootstrapHttpUrl(host, port, bootstrapCode) {
|
|
613
|
+
const url = new URL(`http://${displayHost(host)}:${port}/`);
|
|
614
|
+
url.hash = `bootstrap=${bootstrapCode}`;
|
|
615
|
+
return url.toString();
|
|
616
|
+
}
|
|
342
617
|
function buildClientWsUrl(host, port, token) {
|
|
343
618
|
const url = new URL(`ws://${displayHost(host)}:${port}/ws/client`);
|
|
344
619
|
if (token) url.searchParams.set("token", token);
|
|
345
620
|
return url.toString();
|
|
346
621
|
}
|
|
347
622
|
function hqRuntimeMarkerPath(dataDir) {
|
|
348
|
-
return
|
|
623
|
+
return path2.join(dataDir, "runtime.json");
|
|
349
624
|
}
|
|
350
625
|
function lanIPv4Addresses() {
|
|
351
626
|
const out = [];
|
|
@@ -410,22 +685,59 @@ function sanitizeApiError(err) {
|
|
|
410
685
|
// src/hq-server/snapshot.ts
|
|
411
686
|
var HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS = 100;
|
|
412
687
|
var HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS = 3e3;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
688
|
+
var HQ_BROWSER_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
689
|
+
function sendGuarded(ws, data) {
|
|
690
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
691
|
+
const buffered = Number.isFinite(ws.bufferedAmount) ? ws.bufferedAmount : 0;
|
|
692
|
+
if (buffered + Buffer.byteLength(data, "utf8") > HQ_BROWSER_MAX_BUFFERED_BYTES) {
|
|
693
|
+
try {
|
|
694
|
+
ws.terminate();
|
|
695
|
+
} catch {
|
|
696
|
+
try {
|
|
697
|
+
ws.close(1013, "client cannot keep up");
|
|
698
|
+
} catch {
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
ws.send(data);
|
|
705
|
+
return true;
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
var HQ_STALE_SNAPSHOT_MS = 5 * 6e4;
|
|
711
|
+
function reapStaleClientState(clients, nowMs = Date.now()) {
|
|
712
|
+
for (const client of clients.values()) {
|
|
713
|
+
for (const [sessionId, tracked] of client.sessions) {
|
|
714
|
+
if (nowMs - tracked.receivedAt > HQ_STALE_SNAPSHOT_MS) client.sessions.delete(sessionId);
|
|
715
|
+
}
|
|
716
|
+
for (const [runId, fleet] of client.fleets) {
|
|
717
|
+
if (nowMs - fleet.receivedAt > HQ_STALE_SNAPSHOT_MS) client.fleets.delete(runId);
|
|
718
|
+
}
|
|
719
|
+
for (const [sessionId, tracked] of client.mcpSnapshots) {
|
|
720
|
+
if (nowMs - tracked.receivedAt > HQ_STALE_SNAPSHOT_MS) client.mcpSnapshots.delete(sessionId);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function buildSnapshot(clients, options) {
|
|
725
|
+
reapStaleClientState(clients);
|
|
726
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
727
|
+
const clientRecordById = /* @__PURE__ */ new Map();
|
|
728
|
+
const projectMap = /* @__PURE__ */ new Map();
|
|
729
|
+
const mailboxSummaries = [];
|
|
730
|
+
const sessionById = /* @__PURE__ */ new Map();
|
|
731
|
+
const fleetByRunId = /* @__PURE__ */ new Map();
|
|
732
|
+
for (const client of clients.values()) {
|
|
733
|
+
const machineId = client.machineId || client.project.machineId || "";
|
|
734
|
+
if (!clientRecordById.has(client.clientId)) {
|
|
735
|
+
clientRecordById.set(client.clientId, {
|
|
736
|
+
clientId: client.clientId,
|
|
737
|
+
kind: client.kind,
|
|
738
|
+
machineId,
|
|
739
|
+
...client.hostname ? { hostname: client.hostname } : {},
|
|
740
|
+
...client.pid ? { pid: client.pid } : {},
|
|
429
741
|
...client.version ? { version: client.version } : {},
|
|
430
742
|
connected: true,
|
|
431
743
|
connectedAt: client.connectedAt,
|
|
@@ -630,8 +942,8 @@ function buildSnapshot(clients, options) {
|
|
|
630
942
|
const mcpServers = [];
|
|
631
943
|
const seenMcp = /* @__PURE__ */ new Set();
|
|
632
944
|
for (const client of clients.values()) {
|
|
633
|
-
for (const [sessionId,
|
|
634
|
-
for (const server of servers) {
|
|
945
|
+
for (const [sessionId, tracked] of client.mcpSnapshots.entries()) {
|
|
946
|
+
for (const server of tracked.servers) {
|
|
635
947
|
const key = `${client.clientId}:${sessionId}:${server.name}`;
|
|
636
948
|
if (seenMcp.has(key)) continue;
|
|
637
949
|
seenMcp.add(key);
|
|
@@ -657,619 +969,121 @@ function buildSnapshot(clients, options) {
|
|
|
657
969
|
unreadMailboxMessages: unread,
|
|
658
970
|
incompleteMailboxMessages: incomplete,
|
|
659
971
|
totalCostUsd,
|
|
660
|
-
activeMachines: machines.length,
|
|
661
|
-
activeAgents: totalAgents,
|
|
662
|
-
...options?.tokenStats !== void 0 ? { tokenStats: options.tokenStats } : {}
|
|
663
|
-
}
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
function createSnapshotBroadcaster(clients, browsers, persistence) {
|
|
667
|
-
let cached = "";
|
|
668
|
-
let dirty = true;
|
|
669
|
-
let timer = null;
|
|
670
|
-
let closed = false;
|
|
671
|
-
let lastPersistAt = 0;
|
|
672
|
-
const persistCheckpoint = (snapshot) => {
|
|
673
|
-
if (persistence === void 0) return;
|
|
674
|
-
const now = Date.now();
|
|
675
|
-
if (now - lastPersistAt < HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) return;
|
|
676
|
-
lastPersistAt = now;
|
|
677
|
-
persistence.snapshotStore.save(snapshot);
|
|
678
|
-
};
|
|
679
|
-
const serialize = () => {
|
|
680
|
-
if (!dirty && cached.length > 0) return cached;
|
|
681
|
-
const snapshot = buildSnapshot(clients);
|
|
682
|
-
const msg = { type: "hq.snapshot", snapshot };
|
|
683
|
-
cached = JSON.stringify(msg);
|
|
684
|
-
dirty = false;
|
|
685
|
-
persistCheckpoint(snapshot);
|
|
686
|
-
return cached;
|
|
687
|
-
};
|
|
688
|
-
const flush = () => {
|
|
689
|
-
timer = null;
|
|
690
|
-
if (browsers.size === 0) {
|
|
691
|
-
if (persistence !== void 0 && Date.now() - lastPersistAt >= HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) {
|
|
692
|
-
persistCheckpoint(buildSnapshot(clients));
|
|
693
|
-
}
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
const data = serialize();
|
|
697
|
-
for (const ws of browsers) {
|
|
698
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
699
|
-
}
|
|
700
|
-
};
|
|
701
|
-
return {
|
|
702
|
-
currentSerialized: serialize,
|
|
703
|
-
broadcast: () => {
|
|
704
|
-
if (closed) return;
|
|
705
|
-
dirty = true;
|
|
706
|
-
if (timer !== null) return;
|
|
707
|
-
timer = setTimeout(flush, HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS);
|
|
708
|
-
timer.unref?.();
|
|
709
|
-
},
|
|
710
|
-
close: () => {
|
|
711
|
-
closed = true;
|
|
712
|
-
if (timer !== null) {
|
|
713
|
-
clearTimeout(timer);
|
|
714
|
-
timer = null;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
function buildProjectDetail(clients, projectId) {
|
|
720
|
-
const projectClients = [];
|
|
721
|
-
for (const c of clients.values()) {
|
|
722
|
-
if (c.projectId === projectId) projectClients.push(c);
|
|
723
|
-
}
|
|
724
|
-
if (projectClients.length === 0) return null;
|
|
725
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
726
|
-
const clientRecords = projectClients.map((c) => ({
|
|
727
|
-
clientId: c.clientId,
|
|
728
|
-
kind: c.kind,
|
|
729
|
-
machineId: "",
|
|
730
|
-
...c.hostname ? { hostname: c.hostname } : {},
|
|
731
|
-
...c.pid ? { pid: c.pid } : {},
|
|
732
|
-
...c.version ? { version: c.version } : {},
|
|
733
|
-
connected: true,
|
|
734
|
-
connectedAt: c.connectedAt,
|
|
735
|
-
lastSeenAt: c.lastSeenAt,
|
|
736
|
-
projectId: c.projectId,
|
|
737
|
-
capabilities: c.capabilities
|
|
738
|
-
}));
|
|
739
|
-
const mailboxPayloads = [];
|
|
740
|
-
let latestActivity = now;
|
|
741
|
-
for (const c of projectClients) {
|
|
742
|
-
for (const snap of c.mailboxes.values()) {
|
|
743
|
-
mailboxPayloads.push(snap);
|
|
744
|
-
if (snap.totals.messages > 0) latestActivity = now;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
const primaryProject = projectClients[0].project;
|
|
748
|
-
const machineIds = Array.from(new Set(projectClients.map((client) => client.project.machineId)));
|
|
749
|
-
const project = {
|
|
750
|
-
projectId,
|
|
751
|
-
projectName: primaryProject.projectName || projectId,
|
|
752
|
-
projectRootDisplay: primaryProject.projectRoot,
|
|
753
|
-
machineIds,
|
|
754
|
-
...primaryProject.gitBranch ? { gitBranch: primaryProject.gitBranch } : {},
|
|
755
|
-
activeClients: projectClients.length,
|
|
756
|
-
activeSessions: 0,
|
|
757
|
-
activeSubagents: 0,
|
|
758
|
-
totalCostUsd: 0,
|
|
759
|
-
lastActivityAt: latestActivity,
|
|
760
|
-
status: "active"
|
|
761
|
-
};
|
|
762
|
-
return {
|
|
763
|
-
generatedAt: now,
|
|
764
|
-
project,
|
|
765
|
-
clients: clientRecords,
|
|
766
|
-
mailboxes: mailboxPayloads
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
function broadcastEvent(event, browsers) {
|
|
770
|
-
const msg = { type: "hq.event", event };
|
|
771
|
-
const data = JSON.stringify(msg);
|
|
772
|
-
for (const ws of browsers) {
|
|
773
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
function broadcastCommandStatus(command, browsers) {
|
|
777
|
-
const data = JSON.stringify({ type: "hq.command_status", command });
|
|
778
|
-
for (const ws of browsers) {
|
|
779
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// src/hq-server/ws.ts
|
|
784
|
-
import { randomUUID } from "node:crypto";
|
|
785
|
-
import { WebSocket as WebSocket2 } from "ws";
|
|
786
|
-
import { HQ_PROTOCOL_VERSION, HQ_TRANSCRIPT_TEXT_CAP, parseHqEventPayload, parseHqFrame, redactHqEvent, resolveHqRedactionPolicy, tightenHqRedactionPolicy, tokenHasCapability } from "@wrongstack/core/hq";
|
|
787
|
-
var MAX_EVENT_LOG = 5e3;
|
|
788
|
-
function handleBrowser(ws, snapshotBroadcaster, browsers) {
|
|
789
|
-
browsers.add(ws);
|
|
790
|
-
ws.on("error", (err) => {
|
|
791
|
-
console.warn(
|
|
792
|
-
JSON.stringify({
|
|
793
|
-
level: "warn",
|
|
794
|
-
event: "hq.browser_socket_error",
|
|
795
|
-
message: err instanceof Error ? err.message : String(err),
|
|
796
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
797
|
-
})
|
|
798
|
-
);
|
|
799
|
-
});
|
|
800
|
-
ws.send(snapshotBroadcaster.currentSerialized());
|
|
801
|
-
ws.on("close", () => {
|
|
802
|
-
browsers.delete(ws);
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster, transcripts, agentMessages, persistence, auditLog) {
|
|
806
|
-
let registered = false;
|
|
807
|
-
function persistEvent(event) {
|
|
808
|
-
eventLog.push(event);
|
|
809
|
-
if (eventLog.length > MAX_EVENT_LOG) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
|
810
|
-
if (persistence !== void 0) {
|
|
811
|
-
persistence.eventLog.append(event);
|
|
812
|
-
recordTimeseriesSignal(persistence, event);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
ws.on("error", (err) => {
|
|
816
|
-
console.warn(
|
|
817
|
-
JSON.stringify({
|
|
818
|
-
level: "warn",
|
|
819
|
-
event: "hq.client_socket_error",
|
|
820
|
-
message: err instanceof Error ? err.message : String(err),
|
|
821
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
822
|
-
})
|
|
823
|
-
);
|
|
824
|
-
});
|
|
825
|
-
ws.on("message", (data) => {
|
|
826
|
-
const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data : new TextDecoder().decode(data);
|
|
827
|
-
const parsed = parseHqFrame(raw);
|
|
828
|
-
if (!parsed.ok) {
|
|
829
|
-
const code = parsed.reason === "invalid-json" ? 1003 : 1008;
|
|
830
|
-
ws.close(code, `invalid frame: ${parsed.reason}`);
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
const frame = parsed.frame;
|
|
834
|
-
if (frame.type === "client.hello") {
|
|
835
|
-
if (registered) {
|
|
836
|
-
ws.close(1008, "duplicate client.hello");
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
const payload = frame.payload;
|
|
840
|
-
if (payload.protocolVersion !== HQ_PROTOCOL_VERSION) {
|
|
841
|
-
ws.close(1008, "protocol version mismatch");
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
const canPublishTelemetry = auth.token === void 0 || tokenHasCapability(auth.token, "telemetry.publish");
|
|
845
|
-
const acceptedCapabilities = payload.capabilities.filter(
|
|
846
|
-
(capability) => capability === "control.receive" || canPublishTelemetry
|
|
847
|
-
);
|
|
848
|
-
const declaredRedactionPolicy = resolveHqRedactionPolicy(payload.redactionPolicy);
|
|
849
|
-
for (const [otherWs, otherClient] of clients) {
|
|
850
|
-
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);
|
|
851
|
-
if (otherWs !== ws && (otherClient.clientId === payload.client.clientId || samePublisher && otherClient.sessions.size === 0)) {
|
|
852
|
-
clients.delete(otherWs);
|
|
853
|
-
otherWs.close(4001, "superseded by a newer HQ connection");
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
const client = {
|
|
857
|
-
ws,
|
|
858
|
-
clientId: payload.client.clientId,
|
|
859
|
-
projectId: payload.project.projectId,
|
|
860
|
-
project: payload.project,
|
|
861
|
-
kind: payload.client.kind,
|
|
862
|
-
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
863
|
-
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
864
|
-
...payload.client.hostname ? { hostname: payload.client.hostname } : {},
|
|
865
|
-
...payload.client.pid ? { pid: payload.client.pid } : {},
|
|
866
|
-
...payload.client.version ? { version: payload.client.version } : {},
|
|
867
|
-
capabilities: acceptedCapabilities,
|
|
868
|
-
...auth.token !== void 0 ? { authToken: auth.token } : {},
|
|
869
|
-
declaredRedactionPolicy,
|
|
870
|
-
lastEventSeq: 0,
|
|
871
|
-
mailboxes: /* @__PURE__ */ new Map(),
|
|
872
|
-
machineId: payload.client.machineId || payload.project.machineId,
|
|
873
|
-
sessions: /* @__PURE__ */ new Map(),
|
|
874
|
-
fleets: /* @__PURE__ */ new Map(),
|
|
875
|
-
mcpSnapshots: /* @__PURE__ */ new Map(),
|
|
876
|
-
commandQueue: []
|
|
877
|
-
};
|
|
878
|
-
clients.set(ws, client);
|
|
879
|
-
registered = true;
|
|
880
|
-
const welcome = {
|
|
881
|
-
type: "hq.welcome",
|
|
882
|
-
protocolVersion: HQ_PROTOCOL_VERSION,
|
|
883
|
-
serverTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
884
|
-
acceptedCapabilities,
|
|
885
|
-
// The operator-configured override (from <dataDir>/auth.json) wins
|
|
886
|
-
// over the default. The client learns the *effective* policy.
|
|
887
|
-
redactionPolicy: tightenHqRedactionPolicy(
|
|
888
|
-
declaredRedactionPolicy,
|
|
889
|
-
auth.getOperatorPolicy()
|
|
890
|
-
)
|
|
891
|
-
};
|
|
892
|
-
ws.send(JSON.stringify(welcome));
|
|
893
|
-
if (persistence !== void 0) {
|
|
894
|
-
void persistence.kanban.load(client.projectId).then((payload2) => {
|
|
895
|
-
if (ws.readyState === WebSocket2.OPEN) {
|
|
896
|
-
ws.send(JSON.stringify({ type: "hq.kanban_snapshot", payload: payload2 }));
|
|
897
|
-
}
|
|
898
|
-
}).catch(() => void 0);
|
|
899
|
-
}
|
|
900
|
-
const event = {
|
|
901
|
-
id: randomUUID(),
|
|
902
|
-
type: "client.hello",
|
|
903
|
-
schemaVersion: HQ_PROTOCOL_VERSION,
|
|
904
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
905
|
-
clientId: payload.client.clientId,
|
|
906
|
-
projectId: payload.project.projectId,
|
|
907
|
-
seq: 0,
|
|
908
|
-
payload: { client: payload.client, project: payload.project }
|
|
909
|
-
};
|
|
910
|
-
persistEvent(event);
|
|
911
|
-
snapshotBroadcaster.broadcast();
|
|
912
|
-
broadcastEvent(event, browsers);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
if (!registered) return;
|
|
916
|
-
if (frame.type === "client.command_poll") {
|
|
917
|
-
const client = clients.get(ws);
|
|
918
|
-
if (client) {
|
|
919
|
-
if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
|
|
920
|
-
ws.close(1008, "command poll identity mismatch");
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
924
|
-
const afterId = frame.afterCommandId;
|
|
925
|
-
const limit = frame.limit ?? 25;
|
|
926
|
-
let toSend;
|
|
927
|
-
if (afterId === void 0) {
|
|
928
|
-
toSend = client.commandQueue.slice(0, limit);
|
|
929
|
-
} else {
|
|
930
|
-
const idx = client.commandQueue.findIndex((c) => c.commandId === afterId);
|
|
931
|
-
toSend = (idx >= 0 ? client.commandQueue.slice(idx + 1) : client.commandQueue).slice(
|
|
932
|
-
0,
|
|
933
|
-
limit
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
if (toSend.length > 0) {
|
|
937
|
-
const batch = JSON.stringify({ type: "hq.command_batch", commands: toSend });
|
|
938
|
-
if (ws.readyState === WebSocket2.OPEN) ws.send(batch);
|
|
939
|
-
for (const cmd of toSend) {
|
|
940
|
-
auditLog?.update(cmd.commandId, { status: "delivered" });
|
|
941
|
-
const updated = auditLog?.get(cmd.commandId);
|
|
942
|
-
if (updated !== void 0) broadcastCommandStatus(updated, browsers);
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
if (frame.type === "client.command_ack") {
|
|
949
|
-
const client = clients.get(ws);
|
|
950
|
-
if (client) {
|
|
951
|
-
if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
|
|
952
|
-
ws.close(1008, "command ack identity mismatch");
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
956
|
-
}
|
|
957
|
-
if (client) {
|
|
958
|
-
const updated = auditLog?.updateForClient(frame.commandId, client.clientId, {
|
|
959
|
-
status: "acked",
|
|
960
|
-
ackStatus: frame.status,
|
|
961
|
-
...frame.message !== void 0 ? { ackMessage: frame.message } : {},
|
|
962
|
-
ackedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
963
|
-
});
|
|
964
|
-
if (updated === true) {
|
|
965
|
-
const entry = auditLog?.get(frame.commandId);
|
|
966
|
-
if (entry !== void 0) broadcastCommandStatus(entry, browsers);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
if (frame.type === "client.event") {
|
|
972
|
-
const client = clients.get(ws);
|
|
973
|
-
if (!client) return;
|
|
974
|
-
const incomingEvent = frame.event;
|
|
975
|
-
if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
|
|
976
|
-
ws.close(1008, "event identity mismatch");
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (!tokenHasCapability(client.authToken, "telemetry.publish") && auth.token !== void 0) {
|
|
980
|
-
ws.close(1008, "client token lacks telemetry.publish capability");
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
if (incomingEvent.seq <= client.lastEventSeq) {
|
|
984
|
-
ws.close(1008, "event sequence must increase monotonically");
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
|
|
988
|
-
if (!parsedPayload.ok) {
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
client.lastEventSeq = incomingEvent.seq;
|
|
992
|
-
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
993
|
-
const event = redactHqEvent(
|
|
994
|
-
{ ...incomingEvent, payload: parsedPayload.payload },
|
|
995
|
-
{
|
|
996
|
-
policy: tightenHqRedactionPolicy(
|
|
997
|
-
client.declaredRedactionPolicy,
|
|
998
|
-
auth.getOperatorPolicy()
|
|
999
|
-
),
|
|
1000
|
-
projectRoot: client.project.projectRoot,
|
|
1001
|
-
// Chat-transcript events carry full turns — the generic 500-char
|
|
1002
|
-
// summary cap would truncate them a second time after the
|
|
1003
|
-
// publisher already applied the transcript cap.
|
|
1004
|
-
...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
|
|
1005
|
-
}
|
|
1006
|
-
).value;
|
|
1007
|
-
if (event.type === "client.heartbeat") {
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
if (event.type === "kanban.snapshot") {
|
|
1011
|
-
const payload = event.payload;
|
|
1012
|
-
if (payload.projectId !== client.projectId || persistence === void 0) return;
|
|
1013
|
-
void persistence.kanban.merge(payload).then((merged) => {
|
|
1014
|
-
const touched = /* @__PURE__ */ new Set();
|
|
1015
|
-
for (const record of payload.boards) touched.add(record.boardId);
|
|
1016
|
-
for (const record of payload.tombstones) touched.add(record.boardId);
|
|
1017
|
-
const delta = {
|
|
1018
|
-
projectId: merged.projectId,
|
|
1019
|
-
generatedAt: merged.generatedAt,
|
|
1020
|
-
boards: merged.boards.filter((record) => touched.has(record.boardId)),
|
|
1021
|
-
tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
|
|
1022
|
-
};
|
|
1023
|
-
const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
|
|
1024
|
-
for (const peer of clients.values()) {
|
|
1025
|
-
if (peer.projectId === client.projectId && peer.ws.readyState === WebSocket2.OPEN) {
|
|
1026
|
-
peer.ws.send(message);
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}).catch((error) => {
|
|
1030
|
-
console.warn(
|
|
1031
|
-
JSON.stringify({
|
|
1032
|
-
level: "warn",
|
|
1033
|
-
event: "hq.kanban_merge_failed",
|
|
1034
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1035
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1036
|
-
})
|
|
1037
|
-
);
|
|
1038
|
-
});
|
|
1039
|
-
persistEvent(event);
|
|
1040
|
-
broadcastEvent(event, browsers);
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
if (event.type === "mailbox.snapshot" && client !== void 0) {
|
|
1044
|
-
const payloadResult = parseHqEventPayload(event.type, event.payload);
|
|
1045
|
-
if (payloadResult.ok) {
|
|
1046
|
-
const payload = payloadResult.payload;
|
|
1047
|
-
client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
|
|
1048
|
-
persistEvent(event);
|
|
1049
|
-
snapshotBroadcaster.broadcast();
|
|
1050
|
-
broadcastEvent(event, browsers);
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
if (event.type === "mailbox.event") {
|
|
1056
|
-
const payloadResult = parseHqEventPayload(event.type, event.payload);
|
|
1057
|
-
if (!payloadResult.ok) {
|
|
1058
|
-
return;
|
|
1059
|
-
}
|
|
1060
|
-
const payload = payloadResult.payload;
|
|
1061
|
-
const sanitizedSummary = truncateHqSummary(payload.summary, 280);
|
|
1062
|
-
const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
|
|
1063
|
-
persistEvent(sanitizedEvent);
|
|
1064
|
-
broadcastEvent(sanitizedEvent, browsers);
|
|
1065
|
-
return;
|
|
1066
|
-
}
|
|
1067
|
-
if (event.type === "session.snapshot" && client !== void 0) {
|
|
1068
|
-
const result = parseHqEventPayload(event.type, event.payload);
|
|
1069
|
-
if (result.ok) {
|
|
1070
|
-
const payload = result.payload;
|
|
1071
|
-
client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
|
|
1072
|
-
snapshotBroadcaster.broadcast();
|
|
1073
|
-
}
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
if (event.type === "session.ended" && client !== void 0) {
|
|
1077
|
-
const result = parseHqEventPayload(event.type, event.payload);
|
|
1078
|
-
if (result.ok) {
|
|
1079
|
-
const payload = result.payload;
|
|
1080
|
-
client.sessions.delete(payload.sessionId);
|
|
1081
|
-
client.mcpSnapshots.delete(payload.sessionId);
|
|
1082
|
-
for (const [runId, fleet] of client.fleets) {
|
|
1083
|
-
if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
|
|
1084
|
-
}
|
|
1085
|
-
snapshotBroadcaster.broadcast();
|
|
1086
|
-
}
|
|
1087
|
-
return;
|
|
1088
|
-
}
|
|
1089
|
-
if (event.type === "fleet.snapshot" && client !== void 0) {
|
|
1090
|
-
const result = parseHqEventPayload(event.type, event.payload);
|
|
1091
|
-
if (result.ok) {
|
|
1092
|
-
const payload = result.payload;
|
|
1093
|
-
client.fleets.set(payload.runId, {
|
|
1094
|
-
payload,
|
|
1095
|
-
...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
|
|
1096
|
-
receivedAt: Date.now()
|
|
1097
|
-
});
|
|
1098
|
-
snapshotBroadcaster.broadcast();
|
|
1099
|
-
}
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
if (event.type === "mcp.health.snapshot" && client !== void 0) {
|
|
1103
|
-
const result = parseHqEventPayload(event.type, event.payload);
|
|
1104
|
-
if (result.ok) {
|
|
1105
|
-
const payload = result.payload;
|
|
1106
|
-
const sessionId = event.sessionId || "unknown";
|
|
1107
|
-
const stamped = payload.servers.map((s) => ({
|
|
1108
|
-
...s,
|
|
1109
|
-
projectId: client.projectId,
|
|
1110
|
-
clientId: client.clientId
|
|
1111
|
-
}));
|
|
1112
|
-
client.mcpSnapshots.set(sessionId, stamped);
|
|
1113
|
-
snapshotBroadcaster.broadcast();
|
|
1114
|
-
}
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
if (event.type === "session.transcript" && client !== void 0) {
|
|
1118
|
-
const result = parseHqEventPayload(event.type, event.payload);
|
|
1119
|
-
if (result.ok) {
|
|
1120
|
-
const payload = result.payload;
|
|
1121
|
-
let ring = transcripts.get(payload.sessionId);
|
|
1122
|
-
if (!ring) {
|
|
1123
|
-
ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
|
|
1124
|
-
}
|
|
1125
|
-
for (const entry of payload.entries) ring.entries.push(entry);
|
|
1126
|
-
if (ring.entries.length > TRANSCRIPT_RING_MAX) {
|
|
1127
|
-
ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
|
|
1128
|
-
}
|
|
1129
|
-
transcripts.delete(payload.sessionId);
|
|
1130
|
-
transcripts.set(payload.sessionId, ring);
|
|
1131
|
-
evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
|
|
1132
|
-
broadcastEvent(event, browsers);
|
|
1133
|
-
}
|
|
1134
|
-
return;
|
|
1135
|
-
}
|
|
1136
|
-
if (event.type === "agent.message") {
|
|
1137
|
-
const p = event.payload;
|
|
1138
|
-
const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
|
|
1139
|
-
if (subId) {
|
|
1140
|
-
const key = agentRingKey(event.sessionId, subId);
|
|
1141
|
-
let ring = agentMessages.get(key);
|
|
1142
|
-
if (!ring) ring = [];
|
|
1143
|
-
ring.push(agentMessageToEntry(p));
|
|
1144
|
-
if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
|
|
1145
|
-
agentMessages.delete(key);
|
|
1146
|
-
agentMessages.set(key, ring);
|
|
1147
|
-
evictOldest(agentMessages, MAX_AGENT_RINGS);
|
|
1148
|
-
}
|
|
1149
|
-
persistEvent(event);
|
|
1150
|
-
broadcastEvent(event, browsers);
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
persistEvent(event);
|
|
1154
|
-
broadcastEvent(event, browsers);
|
|
972
|
+
activeMachines: machines.length,
|
|
973
|
+
activeAgents: totalAgents,
|
|
974
|
+
...options?.tokenStats !== void 0 ? { tokenStats: options.tokenStats } : {}
|
|
1155
975
|
}
|
|
1156
|
-
}
|
|
1157
|
-
ws.on("close", () => {
|
|
1158
|
-
clients.delete(ws);
|
|
1159
|
-
snapshotBroadcaster.broadcast();
|
|
1160
|
-
});
|
|
976
|
+
};
|
|
1161
977
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
if (cachedDistDir !== void 0) return cachedDistDir;
|
|
1192
|
-
try {
|
|
1193
|
-
const pkgJson = requireFromHere.resolve("@wrongstack/webui-hq/package.json");
|
|
1194
|
-
const dist = path2.join(path2.dirname(pkgJson), "dist");
|
|
1195
|
-
if (fs2.existsSync(path2.join(dist, "index.html"))) {
|
|
1196
|
-
cachedDistDir = dist;
|
|
1197
|
-
return dist;
|
|
978
|
+
function createSnapshotBroadcaster(clients, browsers, persistence) {
|
|
979
|
+
let cached = "";
|
|
980
|
+
let dirty = true;
|
|
981
|
+
let timer = null;
|
|
982
|
+
let closed = false;
|
|
983
|
+
let lastPersistAt = 0;
|
|
984
|
+
const persistCheckpoint = (snapshot) => {
|
|
985
|
+
if (persistence === void 0) return;
|
|
986
|
+
const now = Date.now();
|
|
987
|
+
if (now - lastPersistAt < HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) return;
|
|
988
|
+
lastPersistAt = now;
|
|
989
|
+
persistence.snapshotStore.save(snapshot);
|
|
990
|
+
};
|
|
991
|
+
const serialize = () => {
|
|
992
|
+
if (!dirty && cached.length > 0) return cached;
|
|
993
|
+
const snapshot = buildSnapshot(clients);
|
|
994
|
+
const msg = { type: "hq.snapshot", snapshot };
|
|
995
|
+
cached = JSON.stringify(msg);
|
|
996
|
+
dirty = false;
|
|
997
|
+
persistCheckpoint(snapshot);
|
|
998
|
+
return cached;
|
|
999
|
+
};
|
|
1000
|
+
const flush = () => {
|
|
1001
|
+
timer = null;
|
|
1002
|
+
if (browsers.size === 0) {
|
|
1003
|
+
if (persistence !== void 0 && Date.now() - lastPersistAt >= HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) {
|
|
1004
|
+
persistCheckpoint(buildSnapshot(clients));
|
|
1005
|
+
}
|
|
1006
|
+
return;
|
|
1198
1007
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1207
|
-
|
|
1008
|
+
const data = serialize();
|
|
1009
|
+
for (const ws of browsers) sendGuarded(ws, data);
|
|
1010
|
+
};
|
|
1011
|
+
return {
|
|
1012
|
+
currentSerialized: serialize,
|
|
1013
|
+
broadcast: () => {
|
|
1014
|
+
if (closed) return;
|
|
1015
|
+
dirty = true;
|
|
1016
|
+
if (timer !== null) return;
|
|
1017
|
+
timer = setTimeout(flush, HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS);
|
|
1018
|
+
timer.unref?.();
|
|
1019
|
+
},
|
|
1020
|
+
close: () => {
|
|
1021
|
+
closed = true;
|
|
1022
|
+
if (timer !== null) {
|
|
1023
|
+
clearTimeout(timer);
|
|
1024
|
+
timer = null;
|
|
1208
1025
|
}
|
|
1209
|
-
const parent = path2.dirname(dir);
|
|
1210
|
-
if (parent === dir) break;
|
|
1211
|
-
dir = parent;
|
|
1212
1026
|
}
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
cachedDistDir = null;
|
|
1216
|
-
return null;
|
|
1217
|
-
}
|
|
1218
|
-
function isInsideDist(filePath, distDir) {
|
|
1219
|
-
const rel = path2.relative(distDir, filePath);
|
|
1220
|
-
return rel !== "" && !rel.startsWith("..") && !path2.isAbsolute(rel);
|
|
1221
|
-
}
|
|
1222
|
-
function isMissingPath(error) {
|
|
1223
|
-
const code = error.code;
|
|
1224
|
-
return code === "ENOENT" || code === "ENOTDIR";
|
|
1225
|
-
}
|
|
1226
|
-
async function readRegularFile(filePath) {
|
|
1227
|
-
try {
|
|
1228
|
-
const info = await fs2.promises.stat(filePath);
|
|
1229
|
-
if (!info.isFile()) return null;
|
|
1230
|
-
return await fs2.promises.readFile(filePath);
|
|
1231
|
-
} catch (error) {
|
|
1232
|
-
if (isMissingPath(error)) return null;
|
|
1233
|
-
throw error;
|
|
1234
|
-
}
|
|
1027
|
+
};
|
|
1235
1028
|
}
|
|
1236
|
-
|
|
1237
|
-
const
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
decodedPath = decodeURIComponent(relPath);
|
|
1241
|
-
} catch {
|
|
1242
|
-
return { handled: false };
|
|
1243
|
-
}
|
|
1244
|
-
const filePath = path2.join(distDir, decodedPath);
|
|
1245
|
-
if (!isInsideDist(filePath, distDir)) {
|
|
1246
|
-
return { handled: false };
|
|
1029
|
+
function buildProjectDetail(clients, projectId) {
|
|
1030
|
+
const projectClients = [];
|
|
1031
|
+
for (const c of clients.values()) {
|
|
1032
|
+
if (c.projectId === projectId) projectClients.push(c);
|
|
1247
1033
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1034
|
+
if (projectClients.length === 0) return null;
|
|
1035
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1036
|
+
const clientRecords = projectClients.map((c) => ({
|
|
1037
|
+
clientId: c.clientId,
|
|
1038
|
+
kind: c.kind,
|
|
1039
|
+
machineId: "",
|
|
1040
|
+
...c.hostname ? { hostname: c.hostname } : {},
|
|
1041
|
+
...c.pid ? { pid: c.pid } : {},
|
|
1042
|
+
...c.version ? { version: c.version } : {},
|
|
1043
|
+
connected: true,
|
|
1044
|
+
connectedAt: c.connectedAt,
|
|
1045
|
+
lastSeenAt: c.lastSeenAt,
|
|
1046
|
+
projectId: c.projectId,
|
|
1047
|
+
capabilities: c.capabilities
|
|
1048
|
+
}));
|
|
1049
|
+
const mailboxPayloads = [];
|
|
1050
|
+
let latestActivity = now;
|
|
1051
|
+
for (const c of projectClients) {
|
|
1052
|
+
for (const snap of c.mailboxes.values()) {
|
|
1053
|
+
mailboxPayloads.push(snap);
|
|
1054
|
+
if (snap.totals.messages > 0) latestActivity = now;
|
|
1260
1055
|
}
|
|
1261
|
-
return { handled: false };
|
|
1262
1056
|
}
|
|
1263
|
-
const
|
|
1264
|
-
const
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1057
|
+
const primaryProject = projectClients[0].project;
|
|
1058
|
+
const machineIds = Array.from(new Set(projectClients.map((client) => client.project.machineId)));
|
|
1059
|
+
const project = {
|
|
1060
|
+
projectId,
|
|
1061
|
+
projectName: primaryProject.projectName || projectId,
|
|
1062
|
+
projectRootDisplay: primaryProject.projectRoot,
|
|
1063
|
+
machineIds,
|
|
1064
|
+
...primaryProject.gitBranch ? { gitBranch: primaryProject.gitBranch } : {},
|
|
1065
|
+
activeClients: projectClients.length,
|
|
1066
|
+
activeSessions: 0,
|
|
1067
|
+
activeSubagents: 0,
|
|
1068
|
+
totalCostUsd: 0,
|
|
1069
|
+
lastActivityAt: latestActivity,
|
|
1070
|
+
status: "active"
|
|
1071
|
+
};
|
|
1072
|
+
return {
|
|
1073
|
+
generatedAt: now,
|
|
1074
|
+
project,
|
|
1075
|
+
clients: clientRecords,
|
|
1076
|
+
mailboxes: mailboxPayloads
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
function broadcastEvent(event, browsers) {
|
|
1080
|
+
const msg = { type: "hq.event", event };
|
|
1081
|
+
const data = JSON.stringify(msg);
|
|
1082
|
+
for (const ws of browsers) sendGuarded(ws, data);
|
|
1083
|
+
}
|
|
1084
|
+
function broadcastCommandStatus(command, browsers) {
|
|
1085
|
+
const data = JSON.stringify({ type: "hq.command_status", command });
|
|
1086
|
+
for (const ws of browsers) sendGuarded(ws, data);
|
|
1273
1087
|
}
|
|
1274
1088
|
|
|
1275
1089
|
// src/hq-server/trust-boundary.ts
|
|
@@ -1319,9 +1133,9 @@ async function authorizeHqCommand(input) {
|
|
|
1319
1133
|
}
|
|
1320
1134
|
|
|
1321
1135
|
// src/hq-server/routes/auth-handlers.ts
|
|
1322
|
-
import { randomUUID
|
|
1136
|
+
import { randomUUID } from "node:crypto";
|
|
1323
1137
|
import {
|
|
1324
|
-
DEFAULT_HQ_REDACTION_POLICY,
|
|
1138
|
+
DEFAULT_HQ_REDACTION_POLICY as DEFAULT_HQ_REDACTION_POLICY2,
|
|
1325
1139
|
hashHqPassword,
|
|
1326
1140
|
isLoopbackHost,
|
|
1327
1141
|
mintHqCookieSecret,
|
|
@@ -1346,7 +1160,7 @@ async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, require
|
|
|
1346
1160
|
...publicOrigin !== void 0 ? { publicOrigin } : {},
|
|
1347
1161
|
secureCookies: secureCookies === true,
|
|
1348
1162
|
loggedIn: auth !== void 0 || localOpenMode,
|
|
1349
|
-
authKind: auth
|
|
1163
|
+
authKind: isCookieAuth(auth) ? "password" : isTokenAuth(auth) ? "token" : localOpenMode ? "open" : void 0
|
|
1350
1164
|
})
|
|
1351
1165
|
);
|
|
1352
1166
|
}
|
|
@@ -1412,8 +1226,8 @@ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, se
|
|
|
1412
1226
|
return;
|
|
1413
1227
|
}
|
|
1414
1228
|
loginAttempts.delete(clientIp);
|
|
1415
|
-
const sessionId =
|
|
1416
|
-
sessions.set(sessionId, { createdAt: Date.now() });
|
|
1229
|
+
const sessionId = randomUUID();
|
|
1230
|
+
sessions.set(sessionId, { createdAt: Date.now(), kind: "password" });
|
|
1417
1231
|
setHqSessionCookie(
|
|
1418
1232
|
res,
|
|
1419
1233
|
serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
|
|
@@ -1429,7 +1243,7 @@ async function handleApiLogout(req, res, mutableAuth, sessions, secureCookies) {
|
|
|
1429
1243
|
mutableAuth,
|
|
1430
1244
|
sessions
|
|
1431
1245
|
);
|
|
1432
|
-
if (auth
|
|
1246
|
+
if (isCookieAuth(auth)) {
|
|
1433
1247
|
const cookies = parseCookieHeader(req.headers.cookie);
|
|
1434
1248
|
const raw = cookies[HQ_SESSION_COOKIE];
|
|
1435
1249
|
if (raw) {
|
|
@@ -1471,7 +1285,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
|
|
|
1471
1285
|
);
|
|
1472
1286
|
return;
|
|
1473
1287
|
}
|
|
1474
|
-
if (auth
|
|
1288
|
+
if (isCookieAuth(auth) && mutableAuth.passwordHash !== void 0) {
|
|
1475
1289
|
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
|
|
1476
1290
|
if (!currentPassword || !await verifyHqPassword(currentPassword, mutableAuth.passwordHash)) {
|
|
1477
1291
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
@@ -1510,7 +1324,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
|
|
|
1510
1324
|
JSON.stringify({
|
|
1511
1325
|
passwordMode: false,
|
|
1512
1326
|
tokenMode: mutableAuth.browserTokens.size > 0,
|
|
1513
|
-
loggedIn: auth
|
|
1327
|
+
loggedIn: !isCookieAuth(auth) && isTokenAuth(auth)
|
|
1514
1328
|
})
|
|
1515
1329
|
);
|
|
1516
1330
|
return;
|
|
@@ -1534,9 +1348,9 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
|
|
|
1534
1348
|
}));
|
|
1535
1349
|
applyAuthFile(mutableAuth, next);
|
|
1536
1350
|
sessions.clear();
|
|
1537
|
-
if (auth
|
|
1538
|
-
const sessionId =
|
|
1539
|
-
sessions.set(sessionId, { createdAt: Date.now() });
|
|
1351
|
+
if (isCookieAuth(auth) || localOpenBootstrap) {
|
|
1352
|
+
const sessionId = randomUUID();
|
|
1353
|
+
sessions.set(sessionId, { createdAt: Date.now(), kind: "password" });
|
|
1540
1354
|
setHqSessionCookie(
|
|
1541
1355
|
res,
|
|
1542
1356
|
serializeHqSessionCookie(sessionId, cookieSecret),
|
|
@@ -1554,7 +1368,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
|
|
|
1554
1368
|
}
|
|
1555
1369
|
function applyAuthFile(mutableAuth, next) {
|
|
1556
1370
|
mutableAuth.operatorPolicy = {
|
|
1557
|
-
...
|
|
1371
|
+
...DEFAULT_HQ_REDACTION_POLICY2,
|
|
1558
1372
|
...next.redactionPolicy ?? {}
|
|
1559
1373
|
};
|
|
1560
1374
|
mutableAuth.operatorPolicyOverride = next.redactionPolicy;
|
|
@@ -1573,6 +1387,76 @@ function applyAuthFile(mutableAuth, next) {
|
|
|
1573
1387
|
mutableAuth.cookieSecret = next.cookieSecret;
|
|
1574
1388
|
mutableAuth.alertRules = next.alertRules;
|
|
1575
1389
|
}
|
|
1390
|
+
async function handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore) {
|
|
1391
|
+
if (!mutableAuth.cookieSecret) {
|
|
1392
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1393
|
+
res.end(
|
|
1394
|
+
JSON.stringify({
|
|
1395
|
+
error: {
|
|
1396
|
+
code: "BOOTSTRAP_UNAVAILABLE",
|
|
1397
|
+
message: "Cookie signing is not configured on this HQ server."
|
|
1398
|
+
}
|
|
1399
|
+
})
|
|
1400
|
+
);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
let body;
|
|
1404
|
+
try {
|
|
1405
|
+
body = JSON.parse(await readRequestBody(req));
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
writeInvalidBody(res, error);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (typeof body.code !== "string" || body.code.length === 0) {
|
|
1411
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1412
|
+
res.end(
|
|
1413
|
+
JSON.stringify({ error: { code: "BAD_REQUEST", message: "code is required" } })
|
|
1414
|
+
);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const entry = bootstrapStore.consume(body.code);
|
|
1418
|
+
if (!entry) {
|
|
1419
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1420
|
+
res.end(
|
|
1421
|
+
JSON.stringify({
|
|
1422
|
+
error: {
|
|
1423
|
+
code: "INVALID_OR_EXPIRED_CODE",
|
|
1424
|
+
message: "The bootstrap code is invalid, already used, or expired."
|
|
1425
|
+
}
|
|
1426
|
+
})
|
|
1427
|
+
);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const tokenLive = [...mutableAuth.browserTokenObjs.values()].some(
|
|
1431
|
+
(obj) => obj.id === entry.tokenId
|
|
1432
|
+
);
|
|
1433
|
+
if (!tokenLive) {
|
|
1434
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1435
|
+
res.end(
|
|
1436
|
+
JSON.stringify({
|
|
1437
|
+
error: {
|
|
1438
|
+
code: "TOKEN_REVOKED",
|
|
1439
|
+
message: "The token that issued this bootstrap code has been revoked."
|
|
1440
|
+
}
|
|
1441
|
+
})
|
|
1442
|
+
);
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const sessionId = randomUUID();
|
|
1446
|
+
sessions.set(sessionId, {
|
|
1447
|
+
createdAt: Date.now(),
|
|
1448
|
+
kind: "token",
|
|
1449
|
+
tokenId: entry.tokenId,
|
|
1450
|
+
...entry.capabilities !== void 0 ? { capabilities: entry.capabilities } : {}
|
|
1451
|
+
});
|
|
1452
|
+
setHqSessionCookie(
|
|
1453
|
+
res,
|
|
1454
|
+
serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
|
|
1455
|
+
secureCookies
|
|
1456
|
+
);
|
|
1457
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1458
|
+
res.end(JSON.stringify({ loggedIn: true }));
|
|
1459
|
+
}
|
|
1576
1460
|
|
|
1577
1461
|
// src/hq-server/routes/system-handlers.ts
|
|
1578
1462
|
async function handleApiSystemUpdate(res) {
|
|
@@ -1665,11 +1549,11 @@ async function handleApiAlerts(req, res, alertEngine) {
|
|
|
1665
1549
|
import * as path3 from "node:path";
|
|
1666
1550
|
import {
|
|
1667
1551
|
buildTranscriptFromEvents,
|
|
1668
|
-
resolveHqDataDir as
|
|
1552
|
+
resolveHqDataDir as resolveHqDataDir3
|
|
1669
1553
|
} from "@wrongstack/core/hq";
|
|
1670
1554
|
async function handleApiSessions(res) {
|
|
1671
1555
|
const { SessionRegistry } = await import("@wrongstack/core/storage");
|
|
1672
|
-
const globalRoot = path3.dirname(
|
|
1556
|
+
const globalRoot = path3.dirname(resolveHqDataDir3());
|
|
1673
1557
|
try {
|
|
1674
1558
|
const registry = new SessionRegistry(globalRoot);
|
|
1675
1559
|
const sessions = await registry.list();
|
|
@@ -1714,7 +1598,7 @@ async function handleApiSessionEvents(req, res, match, transcripts) {
|
|
|
1714
1598
|
res.end(JSON.stringify({ error: "invalid sessionId encoding" }));
|
|
1715
1599
|
return;
|
|
1716
1600
|
}
|
|
1717
|
-
const globalRoot = path3.dirname(
|
|
1601
|
+
const globalRoot = path3.dirname(resolveHqDataDir3());
|
|
1718
1602
|
try {
|
|
1719
1603
|
const registry = new SessionRegistry(globalRoot);
|
|
1720
1604
|
const entry = await registry.get(sessionId).catch(() => null);
|
|
@@ -1849,6 +1733,7 @@ var setHqSecurityHeaders2 = setHqSecurityHeaders;
|
|
|
1849
1733
|
var hasTrustedBrowserOrigin2 = hasTrustedBrowserOrigin;
|
|
1850
1734
|
var authenticateBrowserRequest2 = authenticateBrowserRequest;
|
|
1851
1735
|
var isTokenAuth2 = isTokenAuth;
|
|
1736
|
+
var isCookieAuth2 = isCookieAuth;
|
|
1852
1737
|
var parseHqSessionCookie2 = parseHqSessionCookie;
|
|
1853
1738
|
var parseCookieHeader2 = parseCookieHeader;
|
|
1854
1739
|
var HQ_SESSION_COOKIE2 = HQ_SESSION_COOKIE;
|
|
@@ -1857,6 +1742,7 @@ var readRequestBody2 = readRequestBody;
|
|
|
1857
1742
|
var writeInvalidBody2 = writeInvalidBody;
|
|
1858
1743
|
var sanitizeApiError2 = sanitizeApiError;
|
|
1859
1744
|
var buildHttpUrl2 = buildHttpUrl;
|
|
1745
|
+
var buildBootstrapHttpUrl2 = buildBootstrapHttpUrl;
|
|
1860
1746
|
var buildClientWsUrl2 = buildClientWsUrl;
|
|
1861
1747
|
var agentRingKey2 = agentRingKey;
|
|
1862
1748
|
var agentMessageToEntry2 = agentMessageToEntry;
|
|
@@ -1884,7 +1770,8 @@ function createHqRouter(deps) {
|
|
|
1884
1770
|
authorizeMailboxGateway,
|
|
1885
1771
|
getMailboxGateway,
|
|
1886
1772
|
getTokenStats,
|
|
1887
|
-
trustBoundary
|
|
1773
|
+
trustBoundary,
|
|
1774
|
+
bootstrapStore
|
|
1888
1775
|
} = deps;
|
|
1889
1776
|
return async (req, res) => {
|
|
1890
1777
|
try {
|
|
@@ -1895,7 +1782,7 @@ function createHqRouter(deps) {
|
|
|
1895
1782
|
res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
|
|
1896
1783
|
return;
|
|
1897
1784
|
}
|
|
1898
|
-
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && (requireBrowserAuth || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash)) {
|
|
1785
|
+
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && url.pathname !== "/api/auth/bootstrap" && (requireBrowserAuth || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash)) {
|
|
1899
1786
|
const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
|
|
1900
1787
|
if (!auth) {
|
|
1901
1788
|
const tokenOnly = mutableAuth.browserTokens.size > 0 && !mutableAuth.passwordHash;
|
|
@@ -1930,6 +1817,22 @@ function createHqRouter(deps) {
|
|
|
1930
1817
|
await handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies);
|
|
1931
1818
|
return;
|
|
1932
1819
|
}
|
|
1820
|
+
if (url.pathname === "/api/auth/bootstrap" && req.method === "POST") {
|
|
1821
|
+
if (bootstrapStore) {
|
|
1822
|
+
await handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore);
|
|
1823
|
+
} else {
|
|
1824
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1825
|
+
res.end(
|
|
1826
|
+
JSON.stringify({
|
|
1827
|
+
error: {
|
|
1828
|
+
code: "BOOTSTRAP_UNAVAILABLE",
|
|
1829
|
+
message: "Bootstrap exchange is not enabled on this HQ server."
|
|
1830
|
+
}
|
|
1831
|
+
})
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1933
1836
|
if (url.pathname === "/api/logout" && req.method === "POST") {
|
|
1934
1837
|
await handleApiLogout(req, res, mutableAuth, sessions, secureCookies);
|
|
1935
1838
|
return;
|
|
@@ -2148,8 +2051,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2148
2051
|
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
2149
2052
|
return;
|
|
2150
2053
|
}
|
|
2151
|
-
const
|
|
2152
|
-
const canEnqueue = auth === "cookie" || !inBrowserTokenMode || tokenObj?.capabilities === void 0 || tokenObj.capabilities.includes("control.enqueue");
|
|
2054
|
+
const canEnqueue = isCookieAuth2(auth) ? auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : !inBrowserTokenMode;
|
|
2153
2055
|
if (!canEnqueue) {
|
|
2154
2056
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2155
2057
|
res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
|
|
@@ -2189,7 +2091,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2189
2091
|
);
|
|
2190
2092
|
return;
|
|
2191
2093
|
}
|
|
2192
|
-
const commandId =
|
|
2094
|
+
const commandId = randomUUID2();
|
|
2193
2095
|
const queued = {
|
|
2194
2096
|
commandId,
|
|
2195
2097
|
type: body.type,
|
|
@@ -2205,7 +2107,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2205
2107
|
);
|
|
2206
2108
|
return;
|
|
2207
2109
|
}
|
|
2208
|
-
if (validated.type === "run-command" && !
|
|
2110
|
+
if (validated.type === "run-command" && !tokenHasCapability(target.authToken, "control.execute")) {
|
|
2209
2111
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2210
2112
|
res.end(
|
|
2211
2113
|
JSON.stringify({
|
|
@@ -2214,13 +2116,13 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
|
|
|
2214
2116
|
);
|
|
2215
2117
|
return;
|
|
2216
2118
|
}
|
|
2217
|
-
const enqueuedBy = auth
|
|
2119
|
+
const enqueuedBy = isCookieAuth2(auth) ? auth.tokenId ?? "password-session" : isTokenAuth2(auth) ? auth.id : "open-mode";
|
|
2218
2120
|
const authorization = await authorizeHqCommand({
|
|
2219
2121
|
boundary: trustBoundary,
|
|
2220
2122
|
command: validated,
|
|
2221
2123
|
commandId,
|
|
2222
2124
|
enqueuedBy,
|
|
2223
|
-
authMethod: auth
|
|
2125
|
+
authMethod: isCookieAuth2(auth) ? "session" : "bearer-token",
|
|
2224
2126
|
target
|
|
2225
2127
|
});
|
|
2226
2128
|
if (!authorization.allowed) {
|
|
@@ -2251,8 +2153,7 @@ async function handleApiMailboxSend(_req, res, mutableAuth, sessions, dataDir, g
|
|
|
2251
2153
|
mutableAuth,
|
|
2252
2154
|
sessions
|
|
2253
2155
|
);
|
|
2254
|
-
const
|
|
2255
|
-
const canEnqueue = auth === "cookie" || mutableAuth.browserTokens.size === 0 || token?.capabilities === void 0 || token.capabilities.includes("control.enqueue");
|
|
2156
|
+
const canEnqueue = isCookieAuth2(auth) ? auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : mutableAuth.browserTokens.size === 0;
|
|
2256
2157
|
if (!canEnqueue) {
|
|
2257
2158
|
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2258
2159
|
res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
|
|
@@ -2487,138 +2388,389 @@ function writeHqLanEndpoints(write, handle, browserToken) {
|
|
|
2487
2388
|
`);
|
|
2488
2389
|
}
|
|
2489
2390
|
|
|
2490
|
-
// src/hq-server/
|
|
2491
|
-
import {
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2391
|
+
// src/hq-server/ws.ts
|
|
2392
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2393
|
+
import {
|
|
2394
|
+
HQ_PROTOCOL_VERSION,
|
|
2395
|
+
HQ_TRANSCRIPT_TEXT_CAP,
|
|
2396
|
+
parseHqEventPayload,
|
|
2397
|
+
parseHqFrame,
|
|
2398
|
+
redactHqEvent,
|
|
2399
|
+
resolveHqRedactionPolicy,
|
|
2400
|
+
tightenHqRedactionPolicy,
|
|
2401
|
+
tokenHasCapability as tokenHasCapability2
|
|
2402
|
+
} from "@wrongstack/core/hq";
|
|
2403
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
2404
|
+
var MAX_EVENT_LOG = 5e3;
|
|
2405
|
+
function handleBrowser(ws, snapshotBroadcaster, browsers) {
|
|
2406
|
+
browsers.add(ws);
|
|
2407
|
+
ws.on("error", (err) => {
|
|
2408
|
+
console.warn(
|
|
2409
|
+
JSON.stringify({
|
|
2410
|
+
level: "warn",
|
|
2411
|
+
event: "hq.browser_socket_error",
|
|
2412
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2413
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2414
|
+
})
|
|
2415
|
+
);
|
|
2416
|
+
});
|
|
2417
|
+
sendGuarded(ws, snapshotBroadcaster.currentSerialized());
|
|
2418
|
+
ws.on("close", () => {
|
|
2419
|
+
browsers.delete(ws);
|
|
2420
|
+
});
|
|
2495
2421
|
}
|
|
2496
|
-
function
|
|
2497
|
-
let
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2422
|
+
function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster, transcripts, agentMessages, persistence, auditLog) {
|
|
2423
|
+
let registered = false;
|
|
2424
|
+
function persistEvent(event) {
|
|
2425
|
+
eventLog.push(event);
|
|
2426
|
+
if (eventLog.length > MAX_EVENT_LOG) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
|
2427
|
+
if (persistence !== void 0) {
|
|
2428
|
+
persistence.eventLog.append(event);
|
|
2429
|
+
recordTimeseriesSignal(persistence, event);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
ws.on("error", (err) => {
|
|
2433
|
+
console.warn(
|
|
2434
|
+
JSON.stringify({
|
|
2435
|
+
level: "warn",
|
|
2436
|
+
event: "hq.client_socket_error",
|
|
2437
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2438
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2439
|
+
})
|
|
2440
|
+
);
|
|
2441
|
+
});
|
|
2442
|
+
ws.on("message", (data) => {
|
|
2443
|
+
const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data : new TextDecoder().decode(data);
|
|
2444
|
+
const parsed = parseHqFrame(raw);
|
|
2445
|
+
if (!parsed.ok) {
|
|
2446
|
+
const code = parsed.reason === "invalid-json" ? 1003 : 1008;
|
|
2447
|
+
ws.close(code, `invalid frame: ${parsed.reason}`);
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const frame = parsed.frame;
|
|
2451
|
+
if (frame.type === "client.hello") {
|
|
2452
|
+
if (registered) {
|
|
2453
|
+
ws.close(1008, "duplicate client.hello");
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
const payload = frame.payload;
|
|
2457
|
+
if (payload.protocolVersion !== HQ_PROTOCOL_VERSION) {
|
|
2458
|
+
ws.close(1008, "protocol version mismatch");
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
const canPublishTelemetry = auth.token === void 0 || tokenHasCapability2(auth.token, "telemetry.publish");
|
|
2462
|
+
const acceptedCapabilities = payload.capabilities.filter(
|
|
2463
|
+
(capability) => capability === "control.receive" || canPublishTelemetry
|
|
2464
|
+
);
|
|
2465
|
+
const declaredRedactionPolicy = resolveHqRedactionPolicy(payload.redactionPolicy);
|
|
2466
|
+
for (const [otherWs, otherClient] of clients) {
|
|
2467
|
+
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);
|
|
2468
|
+
if (otherWs !== ws && (otherClient.clientId === payload.client.clientId || samePublisher && otherClient.sessions.size === 0)) {
|
|
2469
|
+
clients.delete(otherWs);
|
|
2470
|
+
otherWs.close(4001, "superseded by a newer HQ connection");
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
const client = {
|
|
2474
|
+
ws,
|
|
2475
|
+
clientId: payload.client.clientId,
|
|
2476
|
+
projectId: payload.project.projectId,
|
|
2477
|
+
project: payload.project,
|
|
2478
|
+
kind: payload.client.kind,
|
|
2479
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2480
|
+
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2481
|
+
...payload.client.hostname ? { hostname: payload.client.hostname } : {},
|
|
2482
|
+
...payload.client.pid ? { pid: payload.client.pid } : {},
|
|
2483
|
+
...payload.client.version ? { version: payload.client.version } : {},
|
|
2484
|
+
capabilities: acceptedCapabilities,
|
|
2485
|
+
...auth.token !== void 0 ? { authToken: auth.token } : {},
|
|
2486
|
+
declaredRedactionPolicy,
|
|
2487
|
+
lastEventSeq: 0,
|
|
2488
|
+
mailboxes: /* @__PURE__ */ new Map(),
|
|
2489
|
+
machineId: payload.client.machineId || payload.project.machineId,
|
|
2490
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
2491
|
+
fleets: /* @__PURE__ */ new Map(),
|
|
2492
|
+
mcpSnapshots: /* @__PURE__ */ new Map(),
|
|
2493
|
+
commandQueue: []
|
|
2525
2494
|
};
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2495
|
+
clients.set(ws, client);
|
|
2496
|
+
registered = true;
|
|
2497
|
+
const welcome = {
|
|
2498
|
+
type: "hq.welcome",
|
|
2499
|
+
protocolVersion: HQ_PROTOCOL_VERSION,
|
|
2500
|
+
serverTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2501
|
+
acceptedCapabilities,
|
|
2502
|
+
// The operator-configured override (from <dataDir>/auth.json) wins
|
|
2503
|
+
// over the default. The client learns the *effective* policy.
|
|
2504
|
+
redactionPolicy: tightenHqRedactionPolicy(
|
|
2505
|
+
declaredRedactionPolicy,
|
|
2506
|
+
auth.getOperatorPolicy()
|
|
2507
|
+
)
|
|
2537
2508
|
};
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2509
|
+
ws.send(JSON.stringify(welcome));
|
|
2510
|
+
if (persistence !== void 0) {
|
|
2511
|
+
void persistence.kanban.load(client.projectId).then((payload2) => {
|
|
2512
|
+
sendGuarded(ws, JSON.stringify({ type: "hq.kanban_snapshot", payload: payload2 }));
|
|
2513
|
+
}).catch(() => void 0);
|
|
2514
|
+
}
|
|
2515
|
+
const event = {
|
|
2516
|
+
id: randomUUID3(),
|
|
2517
|
+
type: "client.hello",
|
|
2518
|
+
schemaVersion: HQ_PROTOCOL_VERSION,
|
|
2519
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2520
|
+
clientId: payload.client.clientId,
|
|
2521
|
+
projectId: payload.project.projectId,
|
|
2522
|
+
seq: 0,
|
|
2523
|
+
payload: { client: payload.client, project: payload.project }
|
|
2524
|
+
};
|
|
2525
|
+
persistEvent(event);
|
|
2526
|
+
snapshotBroadcaster.broadcast();
|
|
2527
|
+
broadcastEvent(event, browsers);
|
|
2528
|
+
return;
|
|
2548
2529
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2530
|
+
if (!registered) return;
|
|
2531
|
+
if (frame.type === "client.command_poll") {
|
|
2532
|
+
const client = clients.get(ws);
|
|
2533
|
+
if (client) {
|
|
2534
|
+
if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
|
|
2535
|
+
ws.close(1008, "command poll identity mismatch");
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2539
|
+
const afterId = frame.afterCommandId;
|
|
2540
|
+
const limit = frame.limit ?? 25;
|
|
2541
|
+
let toSend;
|
|
2542
|
+
if (afterId === void 0) {
|
|
2543
|
+
toSend = client.commandQueue.slice(0, limit);
|
|
2544
|
+
} else {
|
|
2545
|
+
const idx = client.commandQueue.findIndex((c) => c.commandId === afterId);
|
|
2546
|
+
toSend = (idx >= 0 ? client.commandQueue.slice(idx + 1) : client.commandQueue).slice(
|
|
2547
|
+
0,
|
|
2548
|
+
limit
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
if (toSend.length > 0) {
|
|
2552
|
+
const batch = JSON.stringify({ type: "hq.command_batch", commands: toSend });
|
|
2553
|
+
if (ws.readyState === WebSocket2.OPEN) ws.send(batch);
|
|
2554
|
+
for (const cmd of toSend) {
|
|
2555
|
+
auditLog?.update(cmd.commandId, { status: "delivered" });
|
|
2556
|
+
const updated = auditLog?.get(cmd.commandId);
|
|
2557
|
+
if (updated !== void 0) broadcastCommandStatus(updated, browsers);
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
if (frame.type === "client.command_ack") {
|
|
2564
|
+
const client = clients.get(ws);
|
|
2565
|
+
if (client) {
|
|
2566
|
+
if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
|
|
2567
|
+
ws.close(1008, "command ack identity mismatch");
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2571
|
+
}
|
|
2572
|
+
if (client) {
|
|
2573
|
+
const updated = auditLog?.updateForClient(frame.commandId, client.clientId, {
|
|
2574
|
+
status: "acked",
|
|
2575
|
+
ackStatus: frame.status,
|
|
2576
|
+
...frame.message !== void 0 ? { ackMessage: frame.message } : {},
|
|
2577
|
+
ackedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2578
|
+
});
|
|
2579
|
+
if (updated === true) {
|
|
2580
|
+
const entry = auditLog?.get(frame.commandId);
|
|
2581
|
+
if (entry !== void 0) broadcastCommandStatus(entry, browsers);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
if (frame.type === "client.event") {
|
|
2587
|
+
const client = clients.get(ws);
|
|
2588
|
+
if (!client) return;
|
|
2589
|
+
const incomingEvent = frame.event;
|
|
2590
|
+
if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
|
|
2591
|
+
ws.close(1008, "event identity mismatch");
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
if (!tokenHasCapability2(client.authToken, "telemetry.publish") && auth.token !== void 0) {
|
|
2595
|
+
ws.close(1008, "client token lacks telemetry.publish capability");
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
if (incomingEvent.seq <= client.lastEventSeq) {
|
|
2599
|
+
ws.close(1008, "event sequence must increase monotonically");
|
|
2600
|
+
return;
|
|
2559
2601
|
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
-
|
|
2602
|
+
const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
|
|
2603
|
+
if (!parsedPayload.ok) {
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
client.lastEventSeq = incomingEvent.seq;
|
|
2607
|
+
client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2608
|
+
const event = redactHqEvent(
|
|
2609
|
+
{ ...incomingEvent, payload: parsedPayload.payload },
|
|
2610
|
+
{
|
|
2611
|
+
policy: tightenHqRedactionPolicy(
|
|
2612
|
+
client.declaredRedactionPolicy,
|
|
2613
|
+
auth.getOperatorPolicy()
|
|
2614
|
+
),
|
|
2615
|
+
projectRoot: client.project.projectRoot,
|
|
2616
|
+
// Chat-transcript events carry full turns — the generic 500-char
|
|
2617
|
+
// summary cap would truncate them a second time after the
|
|
2618
|
+
// publisher already applied the transcript cap.
|
|
2619
|
+
...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
|
|
2620
|
+
}
|
|
2621
|
+
).value;
|
|
2622
|
+
if (event.type === "client.heartbeat") {
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
if (event.type === "kanban.snapshot") {
|
|
2626
|
+
const payload = event.payload;
|
|
2627
|
+
if (payload.projectId !== client.projectId || persistence === void 0) return;
|
|
2628
|
+
void persistence.kanban.merge(payload).then((merged) => {
|
|
2629
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2630
|
+
for (const record of payload.boards) touched.add(record.boardId);
|
|
2631
|
+
for (const record of payload.tombstones) touched.add(record.boardId);
|
|
2632
|
+
const delta = {
|
|
2633
|
+
projectId: merged.projectId,
|
|
2634
|
+
generatedAt: merged.generatedAt,
|
|
2635
|
+
boards: merged.boards.filter((record) => touched.has(record.boardId)),
|
|
2636
|
+
tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
|
|
2637
|
+
};
|
|
2638
|
+
const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
|
|
2639
|
+
for (const peer of clients.values()) {
|
|
2640
|
+
if (peer.projectId === client.projectId) sendGuarded(peer.ws, message);
|
|
2641
|
+
}
|
|
2642
|
+
}).catch((error) => {
|
|
2643
|
+
console.warn(
|
|
2644
|
+
JSON.stringify({
|
|
2645
|
+
level: "warn",
|
|
2646
|
+
event: "hq.kanban_merge_failed",
|
|
2647
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2648
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2649
|
+
})
|
|
2650
|
+
);
|
|
2651
|
+
});
|
|
2652
|
+
persistEvent(event);
|
|
2653
|
+
broadcastEvent(event, browsers);
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
if (event.type === "mailbox.snapshot" && client !== void 0) {
|
|
2657
|
+
const payloadResult = parseHqEventPayload(event.type, event.payload);
|
|
2658
|
+
if (payloadResult.ok) {
|
|
2659
|
+
const payload = payloadResult.payload;
|
|
2660
|
+
client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
|
|
2661
|
+
persistEvent(event);
|
|
2662
|
+
snapshotBroadcaster.broadcast();
|
|
2663
|
+
broadcastEvent(event, browsers);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (event.type === "mailbox.event") {
|
|
2669
|
+
const payloadResult = parseHqEventPayload(event.type, event.payload);
|
|
2670
|
+
if (!payloadResult.ok) {
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
const payload = payloadResult.payload;
|
|
2674
|
+
const sanitizedSummary = truncateHqSummary(payload.summary, 280);
|
|
2675
|
+
const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
|
|
2676
|
+
persistEvent(sanitizedEvent);
|
|
2677
|
+
broadcastEvent(sanitizedEvent, browsers);
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
if (event.type === "session.snapshot" && client !== void 0) {
|
|
2681
|
+
const result = parseHqEventPayload(event.type, event.payload);
|
|
2682
|
+
if (result.ok) {
|
|
2683
|
+
const payload = result.payload;
|
|
2684
|
+
client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
|
|
2685
|
+
snapshotBroadcaster.broadcast();
|
|
2686
|
+
}
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
if (event.type === "session.ended" && client !== void 0) {
|
|
2690
|
+
const result = parseHqEventPayload(event.type, event.payload);
|
|
2691
|
+
if (result.ok) {
|
|
2692
|
+
const payload = result.payload;
|
|
2693
|
+
client.sessions.delete(payload.sessionId);
|
|
2694
|
+
client.mcpSnapshots.delete(payload.sessionId);
|
|
2695
|
+
for (const [runId, fleet] of client.fleets) {
|
|
2696
|
+
if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
|
|
2697
|
+
}
|
|
2698
|
+
snapshotBroadcaster.broadcast();
|
|
2699
|
+
}
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (event.type === "fleet.snapshot" && client !== void 0) {
|
|
2703
|
+
const result = parseHqEventPayload(event.type, event.payload);
|
|
2704
|
+
if (result.ok) {
|
|
2705
|
+
const payload = result.payload;
|
|
2706
|
+
client.fleets.set(payload.runId, {
|
|
2707
|
+
payload,
|
|
2708
|
+
...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
|
|
2709
|
+
receivedAt: Date.now()
|
|
2710
|
+
});
|
|
2711
|
+
snapshotBroadcaster.broadcast();
|
|
2712
|
+
}
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
if (event.type === "mcp.health.snapshot" && client !== void 0) {
|
|
2716
|
+
const result = parseHqEventPayload(event.type, event.payload);
|
|
2717
|
+
if (result.ok) {
|
|
2718
|
+
const payload = result.payload;
|
|
2719
|
+
const sessionId = event.sessionId || "unknown";
|
|
2720
|
+
const stamped = payload.servers.map((s) => ({
|
|
2721
|
+
...s,
|
|
2722
|
+
projectId: client.projectId,
|
|
2723
|
+
clientId: client.clientId
|
|
2724
|
+
}));
|
|
2725
|
+
client.mcpSnapshots.set(sessionId, { servers: stamped, receivedAt: Date.now() });
|
|
2726
|
+
snapshotBroadcaster.broadcast();
|
|
2727
|
+
}
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
if (event.type === "session.transcript" && client !== void 0) {
|
|
2731
|
+
const result = parseHqEventPayload(event.type, event.payload);
|
|
2732
|
+
if (result.ok) {
|
|
2733
|
+
const payload = result.payload;
|
|
2734
|
+
let ring = transcripts.get(payload.sessionId);
|
|
2735
|
+
if (!ring) {
|
|
2736
|
+
ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
|
|
2737
|
+
}
|
|
2738
|
+
for (const entry of payload.entries) ring.entries.push(entry);
|
|
2739
|
+
if (ring.entries.length > TRANSCRIPT_RING_MAX) {
|
|
2740
|
+
ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
|
|
2741
|
+
}
|
|
2742
|
+
transcripts.delete(payload.sessionId);
|
|
2743
|
+
transcripts.set(payload.sessionId, ring);
|
|
2744
|
+
evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
|
|
2745
|
+
broadcastEvent(event, browsers);
|
|
2746
|
+
}
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
if (event.type === "agent.message") {
|
|
2750
|
+
const p = event.payload;
|
|
2751
|
+
const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
|
|
2752
|
+
if (subId) {
|
|
2753
|
+
const key = agentRingKey(event.sessionId, subId);
|
|
2754
|
+
let ring = agentMessages.get(key);
|
|
2755
|
+
if (!ring) ring = [];
|
|
2756
|
+
ring.push(agentMessageToEntry(p));
|
|
2757
|
+
if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
|
|
2758
|
+
agentMessages.delete(key);
|
|
2759
|
+
agentMessages.set(key, ring);
|
|
2760
|
+
evictOldest(agentMessages, MAX_AGENT_RINGS);
|
|
2761
|
+
}
|
|
2762
|
+
persistEvent(event);
|
|
2763
|
+
broadcastEvent(event, browsers);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
persistEvent(event);
|
|
2767
|
+
broadcastEvent(event, browsers);
|
|
2768
|
+
}
|
|
2596
2769
|
});
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
"HQ public relay requires browser authentication. Set --password or create a browser token first."
|
|
2601
|
-
);
|
|
2602
|
-
}
|
|
2603
|
-
const exposure = assessHqExposure({
|
|
2604
|
-
host,
|
|
2605
|
-
hasBrowserTokens: (authFile.browserTokens ?? []).length > 0,
|
|
2606
|
-
hasPassword: authFile.passwordHash !== void 0,
|
|
2607
|
-
allowInsecure: options.allowInsecureOpen
|
|
2770
|
+
ws.on("close", () => {
|
|
2771
|
+
clients.delete(ws);
|
|
2772
|
+
snapshotBroadcaster.broadcast();
|
|
2608
2773
|
});
|
|
2609
|
-
if (exposure.kind === "refuse") throw new HqInsecureExposureError(exposure.message);
|
|
2610
|
-
if (exposure.kind === "warn") {
|
|
2611
|
-
console.warn(
|
|
2612
|
-
JSON.stringify({
|
|
2613
|
-
level: "warn",
|
|
2614
|
-
event: "hq.insecure_exposure",
|
|
2615
|
-
message: exposure.message,
|
|
2616
|
-
host,
|
|
2617
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2618
|
-
})
|
|
2619
|
-
);
|
|
2620
|
-
}
|
|
2621
|
-
return { host, port, dataDir, firstRunAuth };
|
|
2622
2774
|
}
|
|
2623
2775
|
|
|
2624
2776
|
// src/hq-server.ts
|
|
@@ -2638,11 +2790,28 @@ async function startHqServer(options = {}) {
|
|
|
2638
2790
|
});
|
|
2639
2791
|
return startHqServerWithAuth(options, host, port, dataDir, firstRunAuth);
|
|
2640
2792
|
}
|
|
2641
|
-
function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
2793
|
+
async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
2642
2794
|
const trustBoundary = options.trustBoundary ?? createCompatibilityTrustBoundary({ policyId: "hq-trusted-host-compat-v1" });
|
|
2643
2795
|
const authFile = firstRunAuth.authFile;
|
|
2644
2796
|
const authState = createHqAuthState(authFile, dataDir);
|
|
2645
2797
|
const { mutableAuth } = authState;
|
|
2798
|
+
if (!mutableAuth.cookieSecret && mutableAuth.browserTokens.size > 0) {
|
|
2799
|
+
const secret = mintHqCookieSecret2();
|
|
2800
|
+
const next = await mutateHqAuthFile2(dataDir, (current) => ({
|
|
2801
|
+
...current,
|
|
2802
|
+
cookieSecret: secret
|
|
2803
|
+
}));
|
|
2804
|
+
mutableAuth.cookieSecret = next.cookieSecret;
|
|
2805
|
+
console.warn(
|
|
2806
|
+
JSON.stringify({
|
|
2807
|
+
level: "info",
|
|
2808
|
+
event: "hq.cookie_secret_provisioned",
|
|
2809
|
+
message: "Cookie signing secret provisioned for token-mode bootstrap exchange.",
|
|
2810
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2811
|
+
})
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
const bootstrapStore = new HqBootstrapCodeStore();
|
|
2646
2815
|
console.warn(
|
|
2647
2816
|
JSON.stringify({
|
|
2648
2817
|
level: "info",
|
|
@@ -2696,8 +2865,29 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2696
2865
|
const entries = await fs4.readdir(projectsDir, { withFileTypes: true }).catch(() => []);
|
|
2697
2866
|
await Promise.all(
|
|
2698
2867
|
entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
2699
|
-
const
|
|
2700
|
-
|
|
2868
|
+
const projectDir = path6.join(projectsDir, entry.name);
|
|
2869
|
+
const metadataPath = path6.join(projectDir, ".mailbox-server.json");
|
|
2870
|
+
const ownerPid = await fs4.readFile(metadataPath, "utf8").then((raw) => JSON.parse(raw).pid).catch(() => void 0);
|
|
2871
|
+
if (ownerPid === void 0) return;
|
|
2872
|
+
let ownerAlive = false;
|
|
2873
|
+
try {
|
|
2874
|
+
process.kill(ownerPid, 0);
|
|
2875
|
+
ownerAlive = true;
|
|
2876
|
+
} catch {
|
|
2877
|
+
ownerAlive = false;
|
|
2878
|
+
}
|
|
2879
|
+
if (!ownerAlive) {
|
|
2880
|
+
await fs4.rm(metadataPath, { force: true }).catch(() => {
|
|
2881
|
+
});
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
const mailbox = getSharedProjectMailbox(projectDir);
|
|
2885
|
+
try {
|
|
2886
|
+
await Promise.all([mailbox.getAgentStatuses(), mailbox.purgeClients()]);
|
|
2887
|
+
} finally {
|
|
2888
|
+
await mailbox.close().catch(() => {
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2701
2891
|
})
|
|
2702
2892
|
);
|
|
2703
2893
|
})().catch(() => {
|
|
@@ -2709,14 +2899,27 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2709
2899
|
const tokenMode = mutableAuth.browserTokens.size > 0;
|
|
2710
2900
|
const passwordMode = mutableAuth.passwordHash !== void 0;
|
|
2711
2901
|
if ((tokenMode || passwordMode) && auth === void 0) {
|
|
2712
|
-
return {
|
|
2902
|
+
return {
|
|
2903
|
+
allowed: false,
|
|
2904
|
+
status: 401,
|
|
2905
|
+
body: { error: { code: "UNAUTHORIZED", message: "unauthorized" } }
|
|
2906
|
+
};
|
|
2713
2907
|
}
|
|
2714
2908
|
const token = isTokenAuth2(auth) ? mutableAuth.browserTokenObjs.get(auth.token) : void 0;
|
|
2715
|
-
const canUseMailbox = auth === "
|
|
2909
|
+
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;
|
|
2716
2910
|
if (!canUseMailbox) {
|
|
2717
|
-
return {
|
|
2911
|
+
return {
|
|
2912
|
+
allowed: false,
|
|
2913
|
+
status: 403,
|
|
2914
|
+
body: {
|
|
2915
|
+
error: {
|
|
2916
|
+
code: "FORBIDDEN",
|
|
2917
|
+
message: "forbidden: token lacks control.enqueue capability"
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2718
2921
|
}
|
|
2719
|
-
const identity = isTokenAuth2(auth) ? auth.id : auth
|
|
2922
|
+
const identity = isTokenAuth2(auth) ? auth.id : isCookieAuth2(auth) ? "cookie" : "open";
|
|
2720
2923
|
return { allowed: true, rateLimitKey: `hq:${identity}:${projectDir}` };
|
|
2721
2924
|
};
|
|
2722
2925
|
const getMailboxGateway = (projectDir) => {
|
|
@@ -2768,7 +2971,11 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2768
2971
|
persistence.timeseries.flush();
|
|
2769
2972
|
}, 6e4);
|
|
2770
2973
|
timeseriesFlushTimer.unref?.();
|
|
2771
|
-
const snapshotBroadcaster = createSnapshotBroadcaster(
|
|
2974
|
+
const snapshotBroadcaster = createSnapshotBroadcaster(
|
|
2975
|
+
clients,
|
|
2976
|
+
browsers,
|
|
2977
|
+
persistence
|
|
2978
|
+
);
|
|
2772
2979
|
snapshotBroadcaster.currentSerialized();
|
|
2773
2980
|
const stopAlertEngine = alertEngine.startPeriodic(
|
|
2774
2981
|
() => buildSnapshot(clients, { tokenStats: authState.tokenStats() }),
|
|
@@ -2835,7 +3042,8 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2835
3042
|
secureCookies: options.secureCookies,
|
|
2836
3043
|
authorizeMailboxGateway,
|
|
2837
3044
|
getMailboxGateway,
|
|
2838
|
-
getTokenStats: () => authState.tokenStats()
|
|
3045
|
+
getTokenStats: () => authState.tokenStats(),
|
|
3046
|
+
bootstrapStore
|
|
2839
3047
|
};
|
|
2840
3048
|
const handleRequest = createHqRouter(routerDeps);
|
|
2841
3049
|
const httpServer = http.createServer((req, res) => {
|
|
@@ -2843,7 +3051,10 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2843
3051
|
});
|
|
2844
3052
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
|
|
2845
3053
|
const browserHeartbeatTimer = setInterval(() => {
|
|
2846
|
-
const heartbeat = JSON.stringify({
|
|
3054
|
+
const heartbeat = JSON.stringify({
|
|
3055
|
+
type: "hq.heartbeat",
|
|
3056
|
+
serverTime: (/* @__PURE__ */ new Date()).toISOString()
|
|
3057
|
+
});
|
|
2847
3058
|
for (const browser of browsers) {
|
|
2848
3059
|
if (browser.readyState === WebSocket3.OPEN) browser.send(heartbeat);
|
|
2849
3060
|
}
|
|
@@ -2858,7 +3069,12 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2858
3069
|
}
|
|
2859
3070
|
if (!hasTrustedBrowserOrigin2(req, host, listeningPort, trustedPublicOrigins)) {
|
|
2860
3071
|
socket.write(
|
|
2861
|
-
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
|
|
3072
|
+
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
|
|
3073
|
+
error: {
|
|
3074
|
+
code: "INVALID_ORIGIN",
|
|
3075
|
+
message: "Cross-origin WebSocket upgrade rejected."
|
|
3076
|
+
}
|
|
3077
|
+
})
|
|
2862
3078
|
);
|
|
2863
3079
|
socket.destroy();
|
|
2864
3080
|
return;
|
|
@@ -2868,12 +3084,21 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2868
3084
|
if (needsAuth) {
|
|
2869
3085
|
const supplied = url.searchParams.get("token") ?? "";
|
|
2870
3086
|
const tokenValid = timingSafeTokenMatch(tokenSet, supplied) !== void 0;
|
|
2871
|
-
const cookieValid = pathname === "/ws/browser" && mutableAuth.
|
|
3087
|
+
const cookieValid = pathname === "/ws/browser" && mutableAuth.cookieSecret !== void 0 && (() => {
|
|
2872
3088
|
const cookies = parseCookieHeader2(req.headers.cookie);
|
|
2873
3089
|
const raw = cookies[HQ_SESSION_COOKIE2];
|
|
2874
3090
|
if (!raw) return false;
|
|
2875
3091
|
const sessionId = parseHqSessionCookie2(raw, mutableAuth.cookieSecret);
|
|
2876
|
-
|
|
3092
|
+
if (sessionId === void 0) return false;
|
|
3093
|
+
const session = sessions.get(sessionId);
|
|
3094
|
+
if (!session || Date.now() - session.createdAt >= HQ_SESSION_MAX_AGE_MS)
|
|
3095
|
+
return false;
|
|
3096
|
+
if (session.kind === "token" && session.tokenId !== void 0) {
|
|
3097
|
+
return [...mutableAuth.browserTokenObjs.values()].some(
|
|
3098
|
+
(obj) => obj.id === session.tokenId
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
return true;
|
|
2877
3102
|
})();
|
|
2878
3103
|
if (!tokenValid && !cookieValid) {
|
|
2879
3104
|
socket.write(
|
|
@@ -2937,7 +3162,12 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2937
3162
|
},
|
|
2938
3163
|
{
|
|
2939
3164
|
warn: (msg) => console.warn(
|
|
2940
|
-
JSON.stringify({
|
|
3165
|
+
JSON.stringify({
|
|
3166
|
+
level: "warn",
|
|
3167
|
+
event: "hq.auth.reload_failed",
|
|
3168
|
+
message: msg,
|
|
3169
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3170
|
+
})
|
|
2941
3171
|
)
|
|
2942
3172
|
}
|
|
2943
3173
|
);
|
|
@@ -2969,9 +3199,20 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2969
3199
|
const hqUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`;
|
|
2970
3200
|
await writeHqRuntimeMarker(dataDir, hqUrl).catch(() => {
|
|
2971
3201
|
});
|
|
3202
|
+
const browserTokenObj = mutableAuth.browserTokenObjs.get(browserToken ?? "");
|
|
3203
|
+
let browserUrl;
|
|
3204
|
+
if (browserToken && browserTokenObj) {
|
|
3205
|
+
const code = bootstrapStore.issue({
|
|
3206
|
+
tokenId: browserTokenObj.id,
|
|
3207
|
+
...browserTokenObj.capabilities !== void 0 ? { capabilities: browserTokenObj.capabilities } : {}
|
|
3208
|
+
});
|
|
3209
|
+
browserUrl = buildBootstrapHttpUrl2(host, actualPort, code);
|
|
3210
|
+
} else {
|
|
3211
|
+
browserUrl = buildHttpUrl2(host, actualPort);
|
|
3212
|
+
}
|
|
2972
3213
|
const startupInfo = {
|
|
2973
3214
|
dataDir,
|
|
2974
|
-
browserUrl
|
|
3215
|
+
browserUrl,
|
|
2975
3216
|
clientUrl: buildClientWsUrl2(host, actualPort, clientToken),
|
|
2976
3217
|
clientEnv: {
|
|
2977
3218
|
WRONGSTACK_HQ_URL: hqUrl,
|
|
@@ -2989,7 +3230,9 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
2989
3230
|
trustPublicOrigin: (origin) => {
|
|
2990
3231
|
const parsed = new URL(origin);
|
|
2991
3232
|
if (parsed.protocol !== "https:" || parsed.origin !== origin) {
|
|
2992
|
-
throw new TypeError(
|
|
3233
|
+
throw new TypeError(
|
|
3234
|
+
"Trusted HQ public origin must be an exact HTTPS origin without a path."
|
|
3235
|
+
);
|
|
2993
3236
|
}
|
|
2994
3237
|
trustedPublicOrigins.add(parsed.origin.toLowerCase());
|
|
2995
3238
|
},
|
|
@@ -3007,6 +3250,7 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
|
|
|
3007
3250
|
stopAlertEngine();
|
|
3008
3251
|
authWatcher.close();
|
|
3009
3252
|
snapshotBroadcaster.close();
|
|
3253
|
+
bootstrapStore.clear();
|
|
3010
3254
|
persistence.timeseries.flush();
|
|
3011
3255
|
for (const ws of browsers) ws.close(1001, "HQ shutting down");
|
|
3012
3256
|
for (const ws of clients.keys()) ws.close(1001, "HQ shutting down");
|
|
@@ -3056,4 +3300,4 @@ export {
|
|
|
3056
3300
|
startHqServer,
|
|
3057
3301
|
HqInsecureExposureError2 as HqInsecureExposureError
|
|
3058
3302
|
};
|
|
3059
|
-
//# sourceMappingURL=chunk-
|
|
3303
|
+
//# sourceMappingURL=chunk-OI7GWJDS.js.map
|