@wrongstack/cli 1.0.26 → 1.0.28

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