@wrongstack/cli 0.299.0 → 0.300.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{acp-S4LZQB66.js → acp-IDNS2YVE.js} +3 -3
  2. package/dist/{auth-G5YKKSRH.js → auth-6T6ZOT2R.js} +5 -5
  3. package/dist/auth-menu/loopback-server.d.ts +41 -55
  4. package/dist/auth-menu/openai-codex-oauth.d.ts +36 -123
  5. package/dist/auto-discover-providers-DRW4OZHG.js +8 -0
  6. package/dist/boot/dispatch-webui.d.ts +3 -0
  7. package/dist/{chunk-PEKUKUCH.js → chunk-3MWUZMOL.js} +19 -12
  8. package/dist/{chunk-TGDHN4LM.js → chunk-4GVH7V3S.js} +4 -24
  9. package/dist/{chunk-4U3DMA2Y.js → chunk-4WJKVQJY.js} +15 -20
  10. package/dist/{chunk-2SLSUDYS.js → chunk-GYDQABMA.js} +269 -180
  11. package/dist/{chunk-4HH3YGG4.js → chunk-MRLYDHQ7.js} +8 -5
  12. package/dist/{chunk-66T43Q4Y.js → chunk-N7ULWBO5.js} +4 -3
  13. package/dist/{chunk-ZNZJ34RF.js → chunk-PEMN4T5O.js} +2 -2
  14. package/dist/chunk-PYTFS4MC.js +238 -0
  15. package/dist/{chunk-L5CE52XW.js → chunk-V76R7DC5.js} +25 -19
  16. package/dist/{cli-main-3MQDFSYK.js → cli-main-7B3G2YSG.js} +528 -156
  17. package/dist/{execution-ANOYMWPO.js → execution-SHGM7BVC.js} +50 -5
  18. package/dist/fleet/budget-source.d.ts +18 -0
  19. package/dist/fleet/host-status.d.ts +31 -0
  20. package/dist/fleet/host-types.d.ts +8 -0
  21. package/dist/fleet/host.d.ts +5 -0
  22. package/dist/{hq-BDO56CX6.js → hq-DVDD4XPV.js} +22 -3
  23. package/dist/hq-server/auth-state.d.ts +29 -1
  24. package/dist/hq-server/auth.d.ts +16 -0
  25. package/dist/hq-server/client-address.d.ts +56 -0
  26. package/dist/hq-server/login-attempt-store.d.ts +30 -10
  27. package/dist/hq-server/routes/auth-handlers.d.ts +66 -26
  28. package/dist/hq-server/routes.d.ts +13 -0
  29. package/dist/hq-server/types.d.ts +10 -0
  30. package/dist/{hq-server-RJLJJZU7.js → hq-server-DQHLNHDL.js} +2 -2
  31. package/dist/hq-server.d.ts +19 -0
  32. package/dist/index.js +20 -20
  33. package/dist/{modeldiag-HENYJ4N7.js → modeldiag-YOQZHBQV.js} +2 -2
  34. package/dist/{plugin-usage-VTL7ZTVC.js → plugin-usage-EOG4ET4S.js} +2 -2
  35. package/dist/{plugins-ETY6W3DC.js → plugins-56CMWMPR.js} +3 -3
  36. package/dist/{providers-models-GDELBHZK.js → providers-models-K6RT7ABI.js} +34 -9
  37. package/dist/slash-commands/command-context.d.ts +19 -0
  38. package/dist/webui-server/setup-events.d.ts +17 -0
  39. package/dist/webui-server/terminal-log-view.d.ts +29 -12
  40. package/dist/{webui-server-PCLSXX6L.js → webui-server-G3EKFXLL.js} +109 -38
  41. package/dist/webui-server-options.d.ts +16 -0
  42. package/dist/wiring/brain-and-orchestration.d.ts +3 -0
  43. package/dist/wiring/director-setup.d.ts +4 -0
  44. package/dist/wiring/fallback-gate.d.ts +31 -0
  45. package/dist/wiring/fleet-command-handlers.d.ts +1 -1
  46. package/dist/wiring/plugins.d.ts +2 -8
  47. package/dist/wiring/provider-utility-tools.d.ts +26 -1
  48. package/package.json +23 -23
  49. package/dist/chunk-GO3TJICK.js +0 -489
@@ -182,12 +182,13 @@ async function runWebUIDispatch(ctx) {
182
182
  rebindTodosCheckpoint,
183
183
  agentTranscripts,
184
184
  sddSubagentFactory,
185
- onKanbanDispatch
185
+ onKanbanDispatch,
186
+ getFleetBudget
186
187
  } = ctx;
187
188
  const isSimpleUi = flags["simpleui"] === true;
188
189
  agent.disableInteractiveConfirmation();
189
190
  renderer.setSilent(true);
190
- const { runWebUI } = await import("./webui-server-PCLSXX6L.js");
191
+ const { runWebUI } = await import("./webui-server-G3EKFXLL.js");
191
192
  const flagValue = (names) => {
192
193
  for (const name of names) {
193
194
  if (!Object.hasOwn(flags, name)) continue;
@@ -274,6 +275,7 @@ async function runWebUIDispatch(ctx) {
274
275
  mcpRegistry,
275
276
  subscribeEternalIteration,
276
277
  sessionStore,
278
+ ...getFleetBudget ? { getFleetBudget } : {},
277
279
  sessionsDir: projectSessionsDir,
278
280
  claimSession: activateSessionIdentity ? async (sessionId) => {
279
281
  const previousSessionId = agent.ctx.session?.id ?? session.id;
@@ -336,7 +338,11 @@ async function runWebUIDispatch(ctx) {
336
338
  resolve3(0);
337
339
  }).catch((err) => {
338
340
  renderer.setSilent(false);
339
- console.debug(`[execution] webui error: ${err}`);
341
+ renderer.writeInfo(
342
+ color2.red(
343
+ ` \u2717 ${isSimpleUi ? "SimpleUI" : "WebUI"} failed to start: ${err instanceof Error ? err.message : String(err)}`
344
+ )
345
+ );
340
346
  resolve3(1);
341
347
  });
342
348
  });
@@ -1982,6 +1988,19 @@ async function finalizeExecutionCleanup(input) {
1982
1988
  } catch (_err) {
1983
1989
  }
1984
1990
  await Promise.resolve(detachTodosCheckpoint?.()).catch(() => void 0);
1991
+ try {
1992
+ const { getProcessRegistry: getProcessRegistry2 } = await import("@wrongstack/tools");
1993
+ getProcessRegistry2().killAll({ force: true, includeProtected: true });
1994
+ } catch (err) {
1995
+ console.warn(
1996
+ JSON.stringify({
1997
+ level: "warn",
1998
+ event: "shutdown.process_kill_all_failed",
1999
+ message: `Process registry killAll failed: ${err instanceof Error ? err.message : String(err)}`,
2000
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2001
+ })
2002
+ );
2003
+ }
1985
2004
  await mcpRegistry.stopAll().catch((err) => {
1986
2005
  console.warn(
1987
2006
  JSON.stringify({
@@ -2719,6 +2738,9 @@ function installChimeraCascadeHandler({
2719
2738
  maxIterations: 40,
2720
2739
  maxToolCalls: 200,
2721
2740
  timeoutMs,
2741
+ // Cascade agents are ephemeral infrastructure: like reviewers,
2742
+ // they must not consume the leader's lifetime maxSpawns budget.
2743
+ spawnBudgetExempt: true,
2722
2744
  // Rung 0 stays unpinned so the role model matrix still decides;
2723
2745
  // later rungs pin a model precisely because it just failed.
2724
2746
  ...attempt.tier === "inherit" ? {} : {
@@ -2977,7 +2999,10 @@ function installChimeraReviewHandler({
2977
2999
  timeoutMs,
2978
3000
  provider: attempt.provider,
2979
3001
  model: attempt.model,
2980
- fallbackModels: attempt.fallbackModels
3002
+ fallbackModels: attempt.fallbackModels,
3003
+ // Reviewers are ephemeral background infrastructure: they must
3004
+ // not consume the leader's lifetime maxSpawns budget.
3005
+ spawnBudgetExempt: true
2981
3006
  }),
2982
3007
  // The reviewer is read-only, so a retry re-runs the task verbatim.
2983
3008
  buildTask: () => taskDesc,
@@ -4885,6 +4910,26 @@ async function execute(deps) {
4885
4910
  sddSubagentFactory,
4886
4911
  statusTracker,
4887
4912
  updateInfo: bootUpdateInfo,
4913
+ getFleetBudget: () => {
4914
+ const d = getDirector?.() ?? null;
4915
+ if (!d) return null;
4916
+ const snap = d.fleetManager?.budgetSnapshot?.();
4917
+ const maxSpawns = snap?.maxSpawns ?? d.maxSpawns;
4918
+ const usedSpawns = snap?.usedSpawns ?? d.spawnCount;
4919
+ const remainingSpawns = snap?.remainingSpawns ?? Math.max(
4920
+ 0,
4921
+ (Number.isFinite(maxSpawns) ? maxSpawns : Number.POSITIVE_INFINITY) - usedSpawns
4922
+ );
4923
+ const activeAgents = d.status().subagents.filter((s) => s.status === "running" || s.status === "idle").length;
4924
+ return {
4925
+ maxSpawns,
4926
+ usedSpawns,
4927
+ remainingSpawns,
4928
+ activeAgents,
4929
+ ...snap?.checkpointMaxSpawns !== void 0 ? { checkpointMaxSpawns: snap.checkpointMaxSpawns } : {},
4930
+ ...snap?.ceilingMismatch ? { ceilingMismatch: true } : {}
4931
+ };
4932
+ },
4888
4933
  ...createKanbanDispatchHandler({ config, events, skillLoader, sddSubagentFactory })
4889
4934
  });
4890
4935
  } else {
@@ -4965,4 +5010,4 @@ export {
4965
5010
  execute,
4966
5011
  resolveReviewerFallbackModels
4967
5012
  };
4968
- //# sourceMappingURL=execution-ANOYMWPO.js.map
5013
+ //# sourceMappingURL=execution-SHGM7BVC.js.map
@@ -0,0 +1,18 @@
1
+ import type { Config } from '@wrongstack/core/types';
2
+ export type FleetBudgetSource = 'cli-flag' | 'env' | 'profile' | 'default';
3
+ export interface ResolvedFleetBudget {
4
+ maxConcurrent: number;
5
+ maxConcurrentSource: FleetBudgetSource;
6
+ maxSpawns: number;
7
+ maxSpawnsSource: FleetBudgetSource;
8
+ }
9
+ export declare function resolveFleetBudgetSources(input: {
10
+ flags?: Record<string, string | boolean> | undefined;
11
+ env?: NodeJS.ProcessEnv | undefined;
12
+ config?: Config | undefined;
13
+ /** Built-in maxConcurrent when nothing else wins. */
14
+ defaultMaxConcurrent?: number | undefined;
15
+ /** Built-in maxSpawns when nothing else wins. */
16
+ defaultMaxSpawns?: number | undefined;
17
+ }): ResolvedFleetBudget;
18
+ //# sourceMappingURL=budget-source.d.ts.map
@@ -1,4 +1,27 @@
1
1
  import type { TaskResult } from '@wrongstack/core/types';
2
+ /** Read-only fleet concurrency + lifetime spawn budget snapshot for operators. */
3
+ export interface FleetBudgetView {
4
+ maxConcurrent: number;
5
+ activeAgents: number;
6
+ maxSpawns: number;
7
+ usedSpawns: number;
8
+ remainingSpawns: number;
9
+ maxTokens?: number | undefined;
10
+ usedTokens?: number | undefined;
11
+ remainingTokens?: number | undefined;
12
+ maxCostUsd?: number | undefined;
13
+ usedCostUsd?: number | undefined;
14
+ remainingCostUsd?: number | undefined;
15
+ /** Historical ceiling from checkpoint metadata when it differed from live. */
16
+ checkpointMaxSpawns?: number | undefined;
17
+ ceilingMismatch?: boolean | undefined;
18
+ /** Winning source for maxConcurrent: cli-flag | env | profile | default. */
19
+ maxConcurrentSource?: string | undefined;
20
+ /** Winning source for maxSpawns: cli-flag | env | profile | default. */
21
+ maxSpawnsSource?: string | undefined;
22
+ /** Compact effective-source label for status lines. */
23
+ effectiveSource?: string | undefined;
24
+ }
2
25
  export interface FleetHostStatus {
3
26
  pending: {
4
27
  taskId: string;
@@ -12,6 +35,7 @@ export interface FleetHostStatus {
12
35
  task?: string | undefined;
13
36
  }[];
14
37
  summary: string;
38
+ budget?: FleetBudgetView | undefined;
15
39
  }
16
40
  export interface FleetHostUsage {
17
41
  rows: Array<{
@@ -42,7 +66,14 @@ export interface FleetHostStatusInputs {
42
66
  } | null | undefined;
43
67
  completedResults?: readonly TaskResult[] | null | undefined;
44
68
  shadowTaskIds: ReadonlySet<string>;
69
+ budget?: FleetBudgetView | undefined;
45
70
  }
46
71
  export declare function buildFleetHostStatus(input: FleetHostStatusInputs): FleetHostStatus;
72
+ /** Format a compact multi-line budget block for `/fleet status` and resume logs. */
73
+ export declare function formatFleetBudgetLines(budget: FleetBudgetView, colorize?: {
74
+ bold: (s: string) => string;
75
+ dim: (s: string) => string;
76
+ amber: (s: string) => string;
77
+ }): string[];
47
78
  export declare function aggregateFleetUsage(completed: readonly TaskResult[]): FleetHostUsage;
48
79
  //# sourceMappingURL=host-status.d.ts.map
@@ -58,6 +58,14 @@ export interface MultiAgentHostOptions {
58
58
  };
59
59
  /** Lifetime spawn cap for this Director. CLI default: 64. */
60
60
  maxSpawns?: number | undefined;
61
+ /**
62
+ * Winning configuration sources for concurrency / lifetime spawn ceilings
63
+ * (issue #323). Surfaced in `/fleet status` and WebUI budget views.
64
+ */
65
+ budgetSources?: {
66
+ maxConcurrent?: import('./budget-source.js').FleetBudgetSource | undefined;
67
+ maxSpawns?: import('./budget-source.js').FleetBudgetSource | undefined;
68
+ } | undefined;
61
69
  /** Maximum auto-extensions per subagent per budget kind. */
62
70
  maxBudgetExtensions?: number | undefined;
63
71
  /** Optional global Brain arbiter for director-level policy decisions. */
@@ -203,6 +203,11 @@ export declare class MultiAgentHost {
203
203
  private recordLearningRole;
204
204
  private captureCompletedTaskLearning;
205
205
  status(): FleetHostStatus;
206
+ /**
207
+ * Read-only concurrency + lifetime spawn budget for `/fleet status` and
208
+ * resume diagnostics. Safe when no director/fleet is active yet.
209
+ */
210
+ budgetView(): import('./host-status.js').FleetBudgetView;
206
211
  usage(): FleetHostUsage;
207
212
  manifest(): Promise<string | null>;
208
213
  promoteToDirector(): Promise<Director | null>;
@@ -36,7 +36,7 @@ var hqCmd = async (args, deps) => {
36
36
  return 1;
37
37
  };
38
38
  async function startServer(deps) {
39
- const { startHqServer } = await import("./hq-server-RJLJJZU7.js");
39
+ const { startHqServer } = await import("./hq-server-DQHLNHDL.js");
40
40
  const dataDir = resolveDataDir(deps);
41
41
  const flags = deps.flags ?? {};
42
42
  const host = typeof flags["host"] === "string" ? flags["host"] : HQ_CLI_DEFAULT_HOST;
@@ -56,6 +56,18 @@ async function startServer(deps) {
56
56
  }
57
57
  tokenTtlMs = parsed.value;
58
58
  }
59
+ const rawHops = typeof flags["hq-trusted-proxy-hops"] === "string" ? flags["hq-trusted-proxy-hops"] : void 0;
60
+ let trustedProxyHops;
61
+ if (rawHops !== void 0 && rawHops.length > 0) {
62
+ const parsed = Number.parseInt(rawHops, 10);
63
+ if (!Number.isFinite(parsed) || parsed < 0 || String(parsed) !== rawHops.trim()) {
64
+ deps.renderer.writeError(
65
+ "--hq-trusted-proxy-hops must be a non-negative integer (0 = ignore X-Forwarded-For).\n"
66
+ );
67
+ return 1;
68
+ }
69
+ trustedProxyHops = parsed;
70
+ }
59
71
  if (password !== void 0 && password.length < 8) {
60
72
  deps.renderer.writeError("HQ password must be at least 8 characters.\n");
61
73
  return 1;
@@ -69,7 +81,8 @@ async function startServer(deps) {
69
81
  dataDir,
70
82
  allowInsecureOpen,
71
83
  ...password !== void 0 ? { password } : {},
72
- ...tokenTtlMs !== void 0 ? { tokenTtlMs } : {}
84
+ ...tokenTtlMs !== void 0 ? { tokenTtlMs } : {},
85
+ ...trustedProxyHops !== void 0 ? { trustedProxyHops } : {}
73
86
  });
74
87
  } catch (err) {
75
88
  if (err instanceof HqInsecureExposureError) {
@@ -528,6 +541,12 @@ function printHelp(deps) {
528
541
  `);
529
542
  deps.renderer.write(` --hq-token-ttl <dur> Stamp an expiresAt on first-run tokens (e.g. 1h, 7d, 3600s).
530
543
  `);
544
+ deps.renderer.write(
545
+ ` --hq-trusted-proxy-hops <n> Trust the rightmost n X-Forwarded-For entries when rate-limiting logins.
546
+ Default 0 (ignore the header). Set to the real hop count behind a tunnel,
547
+ otherwise every user shares one backoff bucket. Never guess high.
548
+ `
549
+ );
531
550
  deps.renderer.write(` --open Open the dashboard in the default browser.
532
551
  `);
533
552
  deps.renderer.write(` --client, -c Operate on client tokens instead of browser tokens.
@@ -544,4 +563,4 @@ export {
544
563
  hqCmd,
545
564
  resolveAuditActor
546
565
  };
547
- //# sourceMappingURL=hq-BDO56CX6.js.map
566
+ //# sourceMappingURL=hq-DVDD4XPV.js.map
@@ -5,6 +5,34 @@ export interface HqAuthState {
5
5
  apply(next: HqAuthFile): void;
6
6
  tokenStats(): NonNullable<HqSnapshot['totals']['tokenStats']>;
7
7
  }
8
+ export interface HqAuthStateOptions {
9
+ /**
10
+ * Invoked after every {@link HqAuthState.apply}, with the freshly projected
11
+ * `mutableAuth`. The server wires this to the WS-010 exposure re-assessment
12
+ * so `requireAuthFloor` is re-latched by EVERY path that changes the live
13
+ * credential set — not just the `fs.watch` reload.
14
+ *
15
+ * WS-101: the in-process mutation routes (`DELETE /api/auth/password`, the
16
+ * TOTP endpoints, recovery-code consumption) used to run their own copy of
17
+ * this projection and never touched the floor, so removing the last
18
+ * credential on a non-loopback bind dropped HQ into open mode until the
19
+ * watcher happened to fire. Routing every mutation through `apply` is what
20
+ * makes the latch unconditional.
21
+ */
22
+ onApplied?: ((mutableAuth: HqRouterMutableAuth) => void) | undefined;
23
+ }
24
+ /**
25
+ * Project an `auth.json` document onto the live `mutableAuth` used by every
26
+ * HQ gate. THE single projection — `HqAuthState.apply` is its only caller and
27
+ * every mutation path goes through that, so there is exactly one place where
28
+ * "which tokens are live" is decided.
29
+ *
30
+ * Expired tokens are dropped from BOTH scopes here. The browser scope is also
31
+ * re-checked at the request boundary (`authenticateBrowserRequest`), but the
32
+ * `/ws/client` upgrade gate is a bare set-membership test, so for client
33
+ * tokens this filter is the only expiry enforcement there is.
34
+ */
35
+ export declare function projectAuthFile(mutableAuth: HqRouterMutableAuth, next: HqAuthFile): void;
8
36
  /** Owns the live auth projection, expiry statistics, and reload audit diff. */
9
- export declare function createHqAuthState(authFile: HqAuthFile, dataDir: string): HqAuthState;
37
+ export declare function createHqAuthState(authFile: HqAuthFile, dataDir: string, opts?: HqAuthStateOptions): HqAuthState;
10
38
  //# sourceMappingURL=auth-state.d.ts.map
@@ -42,6 +42,22 @@ export declare function hasTrustedBrowserOrigin(req: http.IncomingMessage, bound
42
42
  export declare function serializeHqSessionCookie(sessionId: string, secret: string): string;
43
43
  export declare function parseHqSessionCookie(value: string, secret: string): string | undefined;
44
44
  export declare function parseCookieHeader(cookieHeader: string | undefined): Record<string, string>;
45
+ /**
46
+ * Read the HQ session cookie, preferring the `__Host-`-prefixed name.
47
+ *
48
+ * WS-103: every reader open-coded `cookies[HQ_SESSION_COOKIE] ??
49
+ * cookies['__Host-hq.session']`, which prefers the UNPREFIXED name. The whole
50
+ * point of the `__Host-` prefix is that a browser refuses to accept such a
51
+ * cookie unless it is Secure, Path=/, and Domain-less — guarantees a plain
52
+ * `hq.session` set by a sibling subdomain does not carry. Reading the weak name
53
+ * first hands that injected value precedence over the hardened one on exactly
54
+ * the deployments (`secure`) where the prefix was doing work.
55
+ *
56
+ * Signature verification still stands behind this, so a forged cookie needs
57
+ * `cookieSecret` — but preference order is the layer whose only job is to stop
58
+ * the attacker-controlled name from winning, so it should not be inverted.
59
+ */
60
+ export declare function readHqSessionCookie(cookieHeader: string | undefined): string | undefined;
45
61
  export declare function setHqSessionCookie(res: http.ServerResponse, value: string, secure?: boolean): void;
46
62
  export declare function clearHqSessionCookie(res: http.ServerResponse, secure?: boolean): void;
47
63
  export declare function isTokenAuth(auth: HqBrowserAuthResult): auth is HqBrowserAuthContext;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Client-address resolution for rate limiting behind a reverse proxy.
3
+ *
4
+ * WS-106: HQ's login backoff keys on `req.socket.remoteAddress`. On a direct
5
+ * bind that is exactly right. Behind the documented public-relay / tunnel
6
+ * deployment (`--hq-public-url`, `requireBrowserAuth`) it is exactly wrong:
7
+ * every request arrives from the tunnel's own address, so all users share one
8
+ * bucket and a single attacker's backoff locks out everyone — while a
9
+ * distributed attacker gets no per-source limiting at all.
10
+ *
11
+ * The fix is NOT to read `X-Forwarded-For` whenever it is present. That header
12
+ * is client-supplied; trusting it unconditionally would let an attacker mint a
13
+ * fresh rate-limit identity per request and remove the limiter entirely, which
14
+ * is strictly worse than the shared bucket. It is only meaningful when the
15
+ * operator states how many proxies actually sit in front of HQ.
16
+ *
17
+ * So resolution is opt-in and counted, following the well-understood
18
+ * `trust proxy: <n>` model:
19
+ *
20
+ * chain = [socket.remoteAddress, ...reverse(X-Forwarded-For)]
21
+ * client = chain[hops]
22
+ *
23
+ * `hops` is how many trusted proxies stand between the client and this server.
24
+ * The default, 0, ignores the header completely and returns the socket peer —
25
+ * byte-for-byte the previous behaviour.
26
+ *
27
+ * Only the rightmost `hops` entries are ever consulted, because those are the
28
+ * ones trusted infrastructure appended. Everything further left is whatever
29
+ * the client claimed and is never read. If the chain is shorter than `hops`
30
+ * (a client that stripped or under-filled the header), resolution falls back
31
+ * to the socket address rather than reaching into attacker-controlled entries:
32
+ * such requests then share the proxy's bucket, which throttles them rather
33
+ * than freeing them.
34
+ *
35
+ * @module hq-server/client-address
36
+ */
37
+ import type * as http from 'node:http';
38
+ /**
39
+ * Parse one `X-Forwarded-For` element into a bare address, or `undefined` when
40
+ * it is not one. Handles `1.2.3.4`, `1.2.3.4:5678`, `[::1]`, and `[::1]:5678`.
41
+ * Anything that does not parse as an IP is dropped — a forwarded chain with a
42
+ * junk entry must not silently become a rate-limit identity of its own.
43
+ */
44
+ export declare function parseForwardedEntry(entry: string): string | undefined;
45
+ /**
46
+ * Resolve the address a rate limiter should key on.
47
+ *
48
+ * @param req - the incoming request.
49
+ * @param trustedProxyHops - how many trusted reverse proxies sit in front of
50
+ * this server. `0` (the default) ignores `X-Forwarded-For` entirely.
51
+ * @returns a bare IP address, or `'unknown'` when the socket has none (a
52
+ * destroyed socket). `'unknown'` is a single shared bucket by design — it
53
+ * should throttle, not exempt.
54
+ */
55
+ export declare function resolveClientAddress(req: http.IncomingMessage, trustedProxyHops?: number): string;
56
+ //# sourceMappingURL=client-address.d.ts.map
@@ -38,27 +38,47 @@ export declare class LoginAttemptStore {
38
38
  set(key: string, entry: LoginAttemptEntry): void;
39
39
  delete(key: string): void;
40
40
  /**
41
- * Compound rate-limit key for public-relay mode: IP + SHA-256(password).
42
- * Limits how often the SAME password can be tried from different IPs,
43
- * so a rotating-IP attacker can't bypass per-IP backoff.
41
+ * Rate-limit key for a *candidate password*, independent of source IP.
42
+ *
43
+ * WS-104: this used to be `cred:${ip}:${hash}`. Embedding the IP made the
44
+ * documented purpose — "limit how often the SAME password can be tried from
45
+ * different IPs" — structurally impossible, because a rotating-IP attacker
46
+ * got a fresh counter with every hop, exactly the case the key exists for.
47
+ * The IP-scoped counter is already kept separately under the bare `ip` key.
48
+ *
49
+ * Backoff caps at 16s (see {@link recordFailure}), so a global per-password
50
+ * counter throttles guessing without becoming a lockout an attacker could
51
+ * aim at the operator.
44
52
  */
45
- static credentialKey(ip: string, password: string): string;
53
+ static credentialKey(password: string): string;
46
54
  /**
47
- * Check both IP and credential keys; return the stricter (later blockedUntil).
55
+ * Check the IP key and when a candidate password is supplied — the
56
+ * credential key too, returning the stricter (later `blockedUntil`).
57
+ *
58
+ * WS-104: `handleApiLogin` called this with `''` before parsing the request
59
+ * body ("we don't have the password yet") and never called it again, so the
60
+ * credential entries {@link recordFailure} wrote were only ever *written*.
61
+ * Callers now re-check once the password is known; passing no password (or
62
+ * an empty one) means "IP scope only".
48
63
  */
49
- checkBlocked(ip: string, password: string): {
64
+ checkBlocked(ip: string, password?: string): {
50
65
  blocked: boolean;
51
66
  retryAfter: number;
52
67
  };
53
68
  /**
54
- * Record a failed attempt on both IP and credential keys.
69
+ * Record a failed attempt on the IP key, and on the credential key when a
70
+ * candidate password is supplied. Omit the password for flows that have no
71
+ * one (2FA verification, TOTP disable) so they do not accumulate a
72
+ * meaningless `cred:sha256("")` counter shared by every such flow.
73
+ *
55
74
  * Returns the updated IP entry (for the response Retry-After header).
56
75
  */
57
- recordFailure(ip: string, password: string, maxBackoffMs?: number): LoginAttemptEntry;
76
+ recordFailure(ip: string, password?: string, maxBackoffMs?: number): LoginAttemptEntry;
58
77
  /**
59
- * Clear both IP and credential entries on successful login.
78
+ * Clear the IP entry — and the credential entry when a password is given —
79
+ * on successful login.
60
80
  */
61
- clearOnSuccess(ip: string, password: string): void;
81
+ clearOnSuccess(ip: string, password?: string): void;
62
82
  /** Number of tracked entries (for diagnostics). */
63
83
  get size(): number;
64
84
  /**
@@ -1,27 +1,59 @@
1
1
  import type * as http from 'node:http';
2
- import { type HqAlertRuleConfig, type HqRedactionPolicy, type HqToken } from '@wrongstack/core/hq';
2
+ import { type HqAuthFile } from '@wrongstack/core/hq';
3
+ import { type HqBrowserAuthResult } from '../auth.js';
3
4
  import type { LoginAttemptStore } from '../login-attempt-store.js';
4
5
  import type { HqRouterMutableAuth, HqSessionEntry } from '../types.js';
6
+ /**
7
+ * Capability required to operate the account-security surface: enrolling or
8
+ * removing 2FA, changing the password without knowing it, listing and revoking
9
+ * browser sessions, and reading the auth audit log.
10
+ *
11
+ * WS-102: these routes used to gate on `if (!auth)` alone, so ANY authenticated
12
+ * principal could reach them — including the least-privileged token HQ mints on
13
+ * first run (`capabilities: ['control.enqueue']`). That token could
14
+ * `POST /api/auth/totp/setup` → `/enable`, receive the recovery codes, and take
15
+ * the `sessions.clear()` that enrollment performs. The operator was then held
16
+ * at `totpRequired: true` on every password login with no authenticator and no
17
+ * recovery codes, and `/totp/disable` demands auth they could no longer obtain:
18
+ * a one-request, unrecoverable lockout from a token scoped to enqueueing
19
+ * commands. Editing `auth.json` by hand was the only way back.
20
+ */
21
+ export declare const HQ_AUTH_ADMIN_CAPABILITY = "auth.admin";
22
+ /**
23
+ * True when `auth` may operate the account-security surface.
24
+ *
25
+ * - Password-origin cookie sessions (`kind: 'cookie'` with no `tokenId`) are
26
+ * the operator themselves — always allowed.
27
+ * - A token with no `capabilities` field is unrestricted by the documented
28
+ * contract in `HqToken.capabilities`, so it is allowed.
29
+ * - A capability-scoped token must list {@link HQ_AUTH_ADMIN_CAPABILITY}.
30
+ *
31
+ * Token-origin cookie sessions resolve their capabilities from the LIVE token
32
+ * record on every request (see `authenticateBrowserRequest`), so upgrading a
33
+ * token to a cookie never widens what it can do here.
34
+ */
35
+ export declare function callerCanAdministerAuth(auth: HqBrowserAuthResult): boolean;
5
36
  export declare function handleApiAuthStatus(req: http.IncomingMessage, res: http.ServerResponse, url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, requireBrowserAuth: boolean | undefined, trustedPublicOrigins: Set<string>, secureCookies: boolean | undefined): Promise<void>;
6
- export declare function handleApiLogin(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, secureCookies: boolean | undefined): Promise<void>;
37
+ export declare function handleApiLogin(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, secureCookies: boolean | undefined, trustedProxyHops: number): Promise<void>;
7
38
  export declare function handleApiLogout(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, secureCookies: boolean | undefined): Promise<void>;
8
- export declare function handleApiPassword(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, secureCookies: boolean | undefined, requireBrowserAuth: boolean | undefined): Promise<void>;
9
- export declare function applyAuthFile(mutableAuth: HqRouterMutableAuth, next: {
10
- browserTokens?: Array<{
11
- token: string;
12
- id: string;
13
- capabilities?: string[];
14
- expiresAt?: string;
15
- }>;
16
- clientTokens?: Array<HqToken>;
17
- redactionPolicy?: Partial<HqRedactionPolicy>;
18
- passwordHash?: string;
19
- cookieSecret?: string;
20
- alertRules?: HqAlertRuleConfig;
21
- totpSecret?: string;
22
- totpPendingSecret?: string;
23
- totpRecoveryCodes?: string[];
24
- }): void;
39
+ export declare function handleApiPassword(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, secureCookies: boolean | undefined, requireBrowserAuth: boolean | undefined, applyAuthFile: ApplyHqAuthFile): Promise<void>;
40
+ /**
41
+ * Applies a freshly-persisted `auth.json` to the live server state.
42
+ *
43
+ * WS-101: this used to be a second, hand-rolled copy of the projection in
44
+ * `hq-server/auth-state.ts`. The two drifted in exactly the ways duplicated
45
+ * security code drifts: this copy skipped the expired-token filter on the
46
+ * CLIENT scope (re-admitting expired `/ws/client` tokens, where the upgrade
47
+ * gate does no expiry check of its own), never refreshed the raw token lists
48
+ * behind `tokenStats()`, and — worst — never re-ran the WS-010 exposure
49
+ * assessment, so `DELETE /api/auth/password` on a non-loopback bind dropped
50
+ * every gate into open mode until the `fs.watch` debounce happened to fire.
51
+ *
52
+ * It is now a function type supplied by the server, bound to
53
+ * `HqAuthState.apply`, so the mutation routes and the reload watcher share one
54
+ * projection and one floor evaluation.
55
+ */
56
+ export type ApplyHqAuthFile = (next: HqAuthFile) => void;
25
57
  export declare function handleApiBootstrap(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, secureCookies: boolean | undefined, bootstrapStore: import('@wrongstack/core/hq').HqBootstrapCodeStore): Promise<void>;
26
58
  /**
27
59
  * WS-065 — exchange a live browser token for an HttpOnly session cookie.
@@ -50,7 +82,7 @@ export declare function handleApiTokenUpgrade(req: http.IncomingMessage, res: ht
50
82
  * session is upgraded to a full session (pending2fa cleared). Recovery codes
51
83
  * are single-use: the matched hash is removed from `auth.json` on success.
52
84
  */
53
- export declare function handleApiLoginVerify(req: http.IncomingMessage, res: http.ServerResponse, _url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, secureCookies: boolean | undefined): Promise<void>;
85
+ export declare function handleApiLoginVerify(req: http.IncomingMessage, res: http.ServerResponse, _url: URL, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, secureCookies: boolean | undefined, applyAuthFile: ApplyHqAuthFile, trustedProxyHops: number): Promise<void>;
54
86
  /**
55
87
  * POST `/api/auth/totp/setup` — generate a new TOTP secret (not yet active).
56
88
  * Returns the base32 secret + otpauth URI for QR display. The secret is
@@ -60,24 +92,32 @@ export declare function handleApiLoginVerify(req: http.IncomingMessage, res: htt
60
92
  *
61
93
  * Requires existing auth (cookie or token) — only the operator can set up 2FA.
62
94
  */
63
- export declare function handleApiTotpSetup(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string): Promise<void>;
95
+ export declare function handleApiTotpSetup(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, applyAuthFile: ApplyHqAuthFile): Promise<void>;
64
96
  /**
65
97
  * POST `/api/auth/totp/enable` — confirm 2FA enrollment by providing a valid
66
98
  * TOTP code. Promotes `totpPendingSecret` → `totpSecret` (active), generates
67
99
  * and returns recovery codes (shown once). After this, 2FA is required for
68
100
  * password login.
69
101
  */
70
- export declare function handleApiTotpEnable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string): Promise<void>;
102
+ export declare function handleApiTotpEnable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, dataDir: string, applyAuthFile: ApplyHqAuthFile): Promise<void>;
71
103
  /**
72
104
  * POST `/api/auth/totp/disable` — remove TOTP 2FA entirely. Requires the
73
105
  * current password (or a valid TOTP code) as confirmation.
74
106
  */
75
- export declare function handleApiTotpDisable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string): Promise<void>;
107
+ export declare function handleApiTotpDisable(req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>, loginAttempts: LoginAttemptStore, dataDir: string, applyAuthFile: ApplyHqAuthFile, trustedProxyHops: number): Promise<void>;
76
108
  /**
77
109
  * GET `/api/auth/sessions` — list all active browser sessions for the
78
- * session-management UI. Returns each session's kind, creation time,
79
- * last-seen time, and a truncated ID (never the full session ID — that
80
- * would allow cookie forgery since the session ID IS the cookie value).
110
+ * session-management UI.
111
+ *
112
+ * The full session ID is returned because the revoke endpoint below is keyed
113
+ * on it. That is safe on its own — the cookie value is `<id>.<HMAC>` and the
114
+ * HMAC needs `cookieSecret` — but it does hand every reader a working revoke
115
+ * handle for every other session, so the route is gated on `auth.admin`
116
+ * (WS-102) rather than on "is authenticated" as it was.
117
+ *
118
+ * The previous docstring claimed the full ID was never returned while the code
119
+ * returned it; the code was right about what the UI needs, the docstring was
120
+ * right about it deserving a gate.
81
121
  */
82
122
  export declare function handleApiAuthSessions(_req: http.IncomingMessage, res: http.ServerResponse, mutableAuth: HqRouterMutableAuth, sessions: Map<string, HqSessionEntry>): void;
83
123
  /**
@@ -16,6 +16,7 @@ import type { createMailboxHttpRouter } from '@wrongstack/core/coordination';
16
16
  import { type Mailbox, type MailboxHttpAccessDecision, type MailboxHttpRateLimiter } from '@wrongstack/core/coordination';
17
17
  import * as HqServerAuth from './auth.js';
18
18
  import * as HqServerUtils from './utils.js';
19
+ import { type ApplyHqAuthFile } from './routes/auth-handlers.js';
19
20
  export declare const setHqSecurityHeaders: typeof HqServerAuth.setHqSecurityHeaders;
20
21
  export declare const hasTrustedBrowserOrigin: typeof HqServerAuth.hasTrustedBrowserOrigin;
21
22
  export declare const authenticateBrowserRequest: typeof HqServerAuth.authenticateBrowserRequest;
@@ -77,6 +78,18 @@ export interface HqRouterDeps {
77
78
  getTokenStats?: (() => HqSnapshot['totals']['tokenStats'] | undefined) | undefined;
78
79
  /** One-time bootstrap code store for token-to-cookie exchange. */
79
80
  bootstrapStore?: import('@wrongstack/core/hq').HqBootstrapCodeStore | undefined;
81
+ /**
82
+ * Applies a freshly-persisted `auth.json` to live server state. Bound to
83
+ * `HqAuthState.apply` so the in-process mutation routes share one projection
84
+ * — and one WS-010 exposure re-assessment — with the reload watcher.
85
+ */
86
+ applyAuthFile: ApplyHqAuthFile;
87
+ /**
88
+ * How many trusted reverse proxies sit in front of this server. Only used to
89
+ * resolve the address the login backoff keys on — see `client-address.ts`.
90
+ * Defaults to 0, which ignores `X-Forwarded-For` entirely.
91
+ */
92
+ trustedProxyHops?: number | undefined;
80
93
  }
81
94
  /**
82
95
  * Create the HTTP request listener that handles all `/api/*` routes as well
@@ -83,6 +83,16 @@ export interface HqRouterMutableAuth {
83
83
  totpPendingSecret?: string | undefined;
84
84
  /** SHA-256 hashes of single-use recovery codes (live-reloaded from auth.json). */
85
85
  totpRecoveryCodes?: string[] | undefined;
86
+ /**
87
+ * Highest TOTP time-step counter already spent on a successful login
88
+ * (RFC 6238 §5.2 single-use). The ±1-step validation window keeps a code
89
+ * arithmetically valid for ~90s, so without this a code captured in transit
90
+ * — shoulder-surfed, read off a notification, replayed from a proxy log —
91
+ * authenticates a second time. Deliberately process-local and NOT persisted
92
+ * to auth.json: a code cannot outlive its own window, so a restart losing
93
+ * the marker costs at most the tail of one step.
94
+ */
95
+ totpLastUsedCounter?: number | undefined;
86
96
  alertRules: HqAlertRuleConfig | undefined;
87
97
  /**
88
98
  * WS-010: set when the live bind would become unauthenticated and
@@ -15,7 +15,7 @@ import {
15
15
  readLocalSubagentTranscript,
16
16
  sanitizeApiError,
17
17
  startHqServer
18
- } from "./chunk-2SLSUDYS.js";
18
+ } from "./chunk-GYDQABMA.js";
19
19
  import "./chunk-Q5GTM25S.js";
20
20
  import "./chunk-7OCVIDC7.js";
21
21
  export {
@@ -36,4 +36,4 @@ export {
36
36
  sanitizeApiError,
37
37
  startHqServer
38
38
  };
39
- //# sourceMappingURL=hq-server-RJLJJZU7.js.map
39
+ //# sourceMappingURL=hq-server-DQHLNHDL.js.map