cbrowser 18.72.5 → 18.73.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 (46) hide show
  1. package/dist/browser/selector-cache.d.ts +11 -0
  2. package/dist/browser/selector-cache.d.ts.map +1 -1
  3. package/dist/browser/selector-cache.js +41 -4
  4. package/dist/browser/selector-cache.js.map +1 -1
  5. package/dist/browser/session-manager.d.ts +10 -3
  6. package/dist/browser/session-manager.d.ts.map +1 -1
  7. package/dist/browser/session-manager.js +38 -7
  8. package/dist/browser/session-manager.js.map +1 -1
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +28 -4
  11. package/dist/browser.js.map +1 -1
  12. package/dist/cli.js +51 -11
  13. package/dist/cli.js.map +1 -1
  14. package/dist/mcp-server-remote.d.ts +45 -0
  15. package/dist/mcp-server-remote.d.ts.map +1 -1
  16. package/dist/mcp-server-remote.js +323 -54
  17. package/dist/mcp-server-remote.js.map +1 -1
  18. package/dist/mcp-server.d.ts.map +1 -1
  19. package/dist/mcp-server.js +8 -0
  20. package/dist/mcp-server.js.map +1 -1
  21. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  22. package/dist/mcp-tools/base/audit-tools.js +8 -4
  23. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  24. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  25. package/dist/mcp-tools/base/persona-comparison-tools.js +0 -2
  26. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  27. package/dist/mcp-tools/base/session-tools.js +3 -3
  28. package/dist/mcp-tools/base/session-tools.js.map +1 -1
  29. package/dist/mcp-tools/index.d.ts.map +1 -1
  30. package/dist/mcp-tools/index.js +13 -4
  31. package/dist/mcp-tools/index.js.map +1 -1
  32. package/dist/mcp-tools/security-layer.d.ts +27 -0
  33. package/dist/mcp-tools/security-layer.d.ts.map +1 -0
  34. package/dist/mcp-tools/security-layer.js +145 -0
  35. package/dist/mcp-tools/security-layer.js.map +1 -0
  36. package/dist/output.d.ts +20 -0
  37. package/dist/output.d.ts.map +1 -1
  38. package/dist/output.js +157 -4
  39. package/dist/output.js.map +1 -1
  40. package/dist/site-model/manager.d.ts.map +1 -1
  41. package/dist/site-model/manager.js +27 -3
  42. package/dist/site-model/manager.js.map +1 -1
  43. package/dist/stealth/framework.d.ts.map +1 -1
  44. package/dist/stealth/framework.js +6 -1
  45. package/dist/stealth/framework.js.map +1 -1
  46. package/package.json +4 -2
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { CBrowser } from "./browser.js";
9
+ import type { PricingTier } from "./mcp-tools/tool-categories.js";
9
10
  import type { ToolRegistrationContext } from "./mcp-tools/types.js";
10
11
  /**
11
12
  * Generate a short browser token that maps to a session's browser.
@@ -19,6 +20,50 @@ export declare function getBrowserByToken(token?: string): Promise<{
19
20
  browser: CBrowser;
20
21
  token: string;
21
22
  }>;
23
+ /**
24
+ * Verdict for an account key. Separates IDENTITY ("is this a real key?") from
25
+ * PRICING ("what plan is it on?"). Those were the same question until 2026-07-26,
26
+ * and conflating them was a complete authentication bypass — see validateAccountKey.
27
+ */
28
+ export type KeyVerdict = {
29
+ valid: true;
30
+ tier: PricingTier;
31
+ } | {
32
+ valid: false;
33
+ reason: "unknown_key" | "backend_unreachable" | "not_an_account_key";
34
+ };
35
+ /**
36
+ * Test seam — drop cached tier entries.
37
+ *
38
+ * Exists so the revocation-eviction path can be exercised without a 30-second
39
+ * sleep. Note the accepted consequence it makes visible: while a cache entry is
40
+ * fresh, `validateAccountKey` returns before asking the CMS, so a revoked key
41
+ * keeps working for up to TIER_CACHE_TTL_MS. That bounded delay is deliberate;
42
+ * the invariant that matters is that once the CMS SAYS invalid, the entry is
43
+ * evicted and the outage grace can never resurrect it.
44
+ */
45
+ export declare function _clearTierCache(): void;
46
+ export declare function validateAccountKey(apiKey: string, deps?: {
47
+ fetch?: typeof fetch;
48
+ cmsUrl?: string;
49
+ }): Promise<KeyVerdict>;
50
+ /**
51
+ * Start the server's background timers.
52
+ *
53
+ * Both of these used to run at MODULE SCOPE, so merely IMPORTING this file
54
+ * started them — including the session reaper, which closes browsers and
55
+ * SIGKILLs Chrome pids every 30 seconds. A unit test importing the module for a
56
+ * single exported function therefore got a Chrome-killing reaper running inside
57
+ * its own process for the rest of the suite. That is how a 267s test run became
58
+ * 438s with intermittent 180s browser timeouts: the reaper was reaping other
59
+ * tests' browsers, and whichever browser-heavy test was unlucky absorbed it.
60
+ * (Diagnosed 2026-07-27 by bisecting a clean 556-pass/0-fail baseline.)
61
+ *
62
+ * A library module must not start lifecycle timers on import. Both handles are
63
+ * unref'd so they can never hold the event loop open on their own, and the
64
+ * function is idempotent so repeated calls are harmless.
65
+ */
66
+ export declare function startBackgroundTimers(): void;
22
67
  /**
23
68
  * Options for starting the remote MCP server
24
69
  */
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-server-remote.d.ts","sourceRoot":"","sources":["../src/mcp-server-remote.ts"],"names":[],"mappings":";AACA;;;;GAIG;AAmCH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAkBxC,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAoBpE;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAK9D;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCrG;AA6iCD;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,4EAA4E;IAC5E,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,uBAAuB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9F;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAGD,YAAY,EAAE,uBAAuB,EAAE,CAAC;AAExC;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAs2B1F"}
1
+ {"version":3,"file":"mcp-server-remote.d.ts","sourceRoot":"","sources":["../src/mcp-server-remote.ts"],"names":[],"mappings":";AACA;;;;GAIG;AAmCH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAiBxC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAoBpE;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAK9D;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAuCrG;AAmhBD;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,GAClC;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,GAAG,qBAAqB,GAAG,oBAAoB,CAAA;CAAE,CAAC;AA2C3F;;;;;;;;;GASG;AACH,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAWD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GACnD,OAAO,CAAC,UAAU,CAAC,CAuDrB;AAsJD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAS5C;AAseD;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,4EAA4E;IAC5E,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,uBAAuB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9F;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAGD,YAAY,EAAE,uBAAuB,EAAE,CAAC;AAExC;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,CAAC,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CA24B1F"}
@@ -46,7 +46,7 @@ import { VERSION } from "./version.js";
46
46
  // getActiveKeyHash is deliberately no longer imported: billing identity is per-request
47
47
  // (see resolveRequestKeyHash). setActiveKeyHash stays for the usage-logging path, which
48
48
  // is per-session rather than per-charge.
49
- import { registerAllPublicTools, setRemoteMode, setActiveTier as setTierGate, setActiveKeyHash } from "./mcp-tools/index.js";
49
+ import { registerAllPublicTools, setRemoteMode, setActiveTier as setTierGate, setActiveKeyHash, isToolAccessible, upgradePrompt } from "./mcp-tools/index.js";
50
50
  // Shared browser instance (single Chromium process)
51
51
  let browser = null;
52
52
  // Stealth state (enterprise integration)
@@ -322,7 +322,9 @@ async function killOrphanedChromeProcesses() {
322
322
  * Periodic cleanup of idle sessions, memory hogs, expired session tracking,
323
323
  * and orphaned Chrome processes.
324
324
  */
325
- setInterval(async () => {
325
+ let sessionCleanupTimer = null;
326
+ let rateLimitCleanupTimer = null;
327
+ const makeSessionCleanupTimer = () => setInterval(async () => {
326
328
  const now = Date.now();
327
329
  // Clean up expired session tracking (older than retention period)
328
330
  for (const [sessionId, info] of expiredSessions) {
@@ -412,12 +414,34 @@ async function validateAuth0Token(token) {
412
414
  return payload;
413
415
  }
414
416
  catch (jwtError) {
415
- // If JWT validation fails, try opaque token validation via userinfo
417
+ // Opaque-token fallback via /userinfo — OFF by default since 2026-07-26.
418
+ //
419
+ // The JWT branch above validates `audience`. This one structurally cannot:
420
+ // /userinfo answers 200 for ANY access token the tenant issued, for any
421
+ // application in it, and returns no audience to check. So it authenticated
422
+ // tokens minted for a different app in the same tenant with zero CMS
423
+ // involvement — the one remaining route to valid:true that the account-key
424
+ // fix did not touch.
425
+ //
426
+ // Defaulting it off is reasoned, not cautious-by-reflex: Auth0 issues an
427
+ // OPAQUE token precisely when no audience was requested, which is exactly the
428
+ // case the audience check exists to reject. And cbrowser's own OAuth flow
429
+ // hands out `cbk_` keys as access tokens, so real customers take the
430
+ // account-key path and are unaffected. AUTH0_ALLOW_OPAQUE_TOKENS=true
431
+ // restores the old behaviour if some integration turns out to need it, and
432
+ // the rejection is logged either way so it is diagnosable, not mysterious.
433
+ if (process.env.AUTH0_ALLOW_OPAQUE_TOKENS !== "true") {
434
+ console.error("[Auth0] Rejected an opaque access token: /userinfo exposes no audience to verify. Set AUTH0_ALLOW_OPAQUE_TOKENS=true to permit it.");
435
+ return null;
436
+ }
416
437
  try {
417
438
  const userinfoResponse = await fetch(`https://${auth0.domain}/userinfo`, {
418
439
  headers: {
419
440
  Authorization: `Bearer ${token}`,
420
441
  },
442
+ // Attacker-triggered outbound request on a pre-auth path: it must not be
443
+ // able to hold the request open indefinitely.
444
+ signal: AbortSignal.timeout(AUTH_BACKEND_TIMEOUT_MS),
421
445
  });
422
446
  if (userinfoResponse.ok) {
423
447
  const userinfo = await userinfoResponse.json();
@@ -426,10 +450,11 @@ async function validateAuth0Token(token) {
426
450
  email: userinfo.email,
427
451
  name: userinfo.name,
428
452
  };
429
- // Cache opaque tokens for 30 minutes
453
+ // 5 minutes, not 30: an opaque token carries no readable expiry, so a
454
+ // long cache means a revoked token keeps working for that whole window.
430
455
  tokenCache.set(token, {
431
456
  payload,
432
- expiry: Date.now() + 30 * 60 * 1000 - TOKEN_CACHE_MARGIN,
457
+ expiry: Date.now() + 5 * 60 * 1000 - TOKEN_CACHE_MARGIN,
433
458
  });
434
459
  return payload;
435
460
  }
@@ -503,6 +528,10 @@ const tokenToKeyHash = new Map();
503
528
  * cache is a micro-optimisation and a short window costs almost nothing.
504
529
  */
505
530
  const tierCache = new Map();
531
+ /** Pre-auth request bodies are capped; see the read loop in handleMcpRequest. */
532
+ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
533
+ /** Bound the CMS deduct call; an unanswered one used to hang the whole request. */
534
+ const CREDIT_DEDUCT_TIMEOUT_MS = 5_000;
506
535
  const TIER_CACHE_TTL_MS = 30 * 1000;
507
536
  /**
508
537
  * Extract the raw API key from an incoming HTTP request.
@@ -542,10 +571,131 @@ async function resolveRequestKeyHash(req) {
542
571
  return mapped.keyHash;
543
572
  return null;
544
573
  }
574
+ /**
575
+ * Validate an account key against the CMS and report BOTH validity and tier.
576
+ *
577
+ * Why this exists (2026-07-26): `validateAuth` used to authenticate with
578
+ *
579
+ * const tier = await resolveApiKeyTier(rawKey);
580
+ * if (tier) return { valid: true };
581
+ *
582
+ * and `resolveApiKeyTier` returns the string "free" on every failure path — CMS
583
+ * says the key is unknown, CMS returns non-2xx, CMS is unreachable. "free" is
584
+ * truthy, so EVERY `cbk_`-prefixed string authenticated, and the `valid: false`
585
+ * line below that call was unreachable code. Probed live on 2026-07-26:
586
+ * Verified live before the fix: a syntactically well-formed account key that the
587
+ * backend did not recognise was nonetheless authenticated, while tokens without
588
+ * the account-key prefix were correctly rejected. The prefix was the entire
589
+ * check. (Reproduction detail withheld until the fix is published and adopted.)
590
+ *
591
+ * The failure branches are enumerated explicitly rather than collapsed into a
592
+ * catch-all, because a catch-all here is precisely what failed open:
593
+ * - CMS answers, key unknown -> unknown_key (reject)
594
+ * - CMS answers non-2xx -> backend_unreachable (reject, do not guess)
595
+ * - CMS unreachable / throws -> backend_unreachable (reject; a CMS outage
596
+ * must stop auth, not open it)
597
+ * Only an explicit `valid: true` from the CMS authenticates.
598
+ */
599
+ /** How long to wait on the CMS before treating it as unreachable. */
600
+ const AUTH_BACKEND_TIMEOUT_MS = 3_000;
601
+ /**
602
+ * How long past its TTL a previously-validated key may be served when the CMS
603
+ * cannot be reached.
604
+ *
605
+ * Without this, failing closed turns a routine 40-second CMS redeploy into a
606
+ * total auth outage for every MCP user, because a cache entry can only be
607
+ * created by a live success. This does NOT reopen the bypass: the entry cannot
608
+ * exist unless the CMS once answered `valid: true` for that exact key in this
609
+ * process. It converts a hard outage into a bounded revocation delay, and an
610
+ * explicit "invalid" answer still evicts immediately.
611
+ */
612
+ const AUTH_STALE_GRACE_MS = 5 * 60_000;
613
+ /**
614
+ * Test seam — drop cached tier entries.
615
+ *
616
+ * Exists so the revocation-eviction path can be exercised without a 30-second
617
+ * sleep. Note the accepted consequence it makes visible: while a cache entry is
618
+ * fresh, `validateAccountKey` returns before asking the CMS, so a revoked key
619
+ * keeps working for up to TIER_CACHE_TTL_MS. That bounded delay is deliberate;
620
+ * the invariant that matters is that once the CMS SAYS invalid, the entry is
621
+ * evicted and the outage grace can never resurrect it.
622
+ */
623
+ export function _clearTierCache() {
624
+ tierCache.clear();
625
+ }
626
+ /** Serve a previously-validated key during a backend outage, or reject. */
627
+ function staleServe(keyHash) {
628
+ const cached = tierCache.get(keyHash);
629
+ if (cached && cached.expiresAt + AUTH_STALE_GRACE_MS > Date.now()) {
630
+ return { valid: true, tier: cached.tier };
631
+ }
632
+ return { valid: false, reason: "backend_unreachable" };
633
+ }
634
+ export async function validateAccountKey(apiKey, deps = {}) {
635
+ if (!apiKey.startsWith("cbk_"))
636
+ return { valid: false, reason: "not_an_account_key" };
637
+ const { createHash } = await import("crypto");
638
+ const keyHash = createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
639
+ const cached = tierCache.get(keyHash);
640
+ if (cached && cached.expiresAt > Date.now())
641
+ return { valid: true, tier: cached.tier };
642
+ const doFetch = deps.fetch ?? fetch;
643
+ const cmsUrl = deps.cmsUrl ?? process.env.CMS_URL ?? "http://localhost:3200";
644
+ let res;
645
+ try {
646
+ // A timeout is not optional here. Without one, a CMS whose event loop is
647
+ // blocked (a long SQLite write, a wedged worker) accepts the connection and
648
+ // never answers, so EVERY MCP request hangs at auth instead of returning a
649
+ // clean 401. A hang is worse than either the bypass or the rejection,
650
+ // because clients retry and pile up. (2026-07-26, cross-vendor audit.)
651
+ res = await doFetch(`${cmsUrl}/api/accounts/validate-key`, {
652
+ headers: { "X-API-Key": apiKey },
653
+ signal: AbortSignal.timeout(AUTH_BACKEND_TIMEOUT_MS),
654
+ });
655
+ }
656
+ catch {
657
+ return staleServe(keyHash);
658
+ }
659
+ // The CMS answers an unknown or revoked key with 401 (and a missing one with
660
+ // 400) — NOT 200-with-valid:false. Verified against
661
+ // cbrowser-web/cms/routes/accounts.ts:403. Those are explicit answers, so they
662
+ // must evict and reject, never route to the outage grace: mapping a 401 to
663
+ // "backend unreachable" would let a key revoked seconds ago ride the 5-minute
664
+ // stale-serve window. Only 5xx and transport failures are genuine outages.
665
+ // (2026-07-26, caught by probing the live CMS after the deploy rather than
666
+ // trusting the shape the fix assumed.)
667
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
668
+ tierCache.delete(keyHash);
669
+ return { valid: false, reason: "unknown_key" };
670
+ }
671
+ if (!res.ok)
672
+ return staleServe(keyHash);
673
+ let data;
674
+ try {
675
+ data = await res.json();
676
+ }
677
+ catch {
678
+ return staleServe(keyHash);
679
+ }
680
+ // An explicit "this key is not valid" is a DIFFERENT answer from "I could not
681
+ // ask" — it must never fall through to the stale-serve grace, or a revoked key
682
+ // would keep working for the whole grace window.
683
+ if (data.valid !== true) {
684
+ tierCache.delete(keyHash);
685
+ return { valid: false, reason: "unknown_key" };
686
+ }
687
+ const tier = data.tier || "free";
688
+ tierCache.set(keyHash, { tier, expiresAt: Date.now() + TIER_CACHE_TTL_MS });
689
+ return { valid: true, tier };
690
+ }
545
691
  /**
546
692
  * Resolve the pricing tier for an API key by calling the CMS backend.
547
693
  * Returns null for non-cbk keys (server admin keys pass through as-is).
548
694
  * Caches results for 5 minutes.
695
+ *
696
+ * PRICING ONLY. This runs AFTER authentication has already succeeded, so its
697
+ * "free" fallback is a safe-by-default *billing* decision. It must never be used
698
+ * to decide whether a caller is authenticated — use validateAccountKey for that.
549
699
  */
550
700
  async function resolveApiKeyTier(apiKey) {
551
701
  // Only resolve cbk_ keys (user account keys)
@@ -656,7 +806,7 @@ function checkRateLimit(req, tier) {
656
806
  return { allowed: true, remaining: remaining - 1, resetTime, limit: limits.requests };
657
807
  }
658
808
  // Cleanup old rate limit entries every 10 minutes
659
- setInterval(() => {
809
+ const makeRateLimitCleanupTimer = () => setInterval(() => {
660
810
  const now = Date.now();
661
811
  for (const [key, entry] of rateLimitStore.entries()) {
662
812
  entry.requests = entry.requests.filter(t => t > now - RATE_LIMIT_WINDOW_MS);
@@ -665,6 +815,32 @@ setInterval(() => {
665
815
  }
666
816
  }
667
817
  }, 10 * 60 * 1000);
818
+ /**
819
+ * Start the server's background timers.
820
+ *
821
+ * Both of these used to run at MODULE SCOPE, so merely IMPORTING this file
822
+ * started them — including the session reaper, which closes browsers and
823
+ * SIGKILLs Chrome pids every 30 seconds. A unit test importing the module for a
824
+ * single exported function therefore got a Chrome-killing reaper running inside
825
+ * its own process for the rest of the suite. That is how a 267s test run became
826
+ * 438s with intermittent 180s browser timeouts: the reaper was reaping other
827
+ * tests' browsers, and whichever browser-heavy test was unlucky absorbed it.
828
+ * (Diagnosed 2026-07-27 by bisecting a clean 556-pass/0-fail baseline.)
829
+ *
830
+ * A library module must not start lifecycle timers on import. Both handles are
831
+ * unref'd so they can never hold the event loop open on their own, and the
832
+ * function is idempotent so repeated calls are harmless.
833
+ */
834
+ export function startBackgroundTimers() {
835
+ if (!sessionCleanupTimer) {
836
+ sessionCleanupTimer = makeSessionCleanupTimer();
837
+ sessionCleanupTimer.unref();
838
+ }
839
+ if (!rateLimitCleanupTimer) {
840
+ rateLimitCleanupTimer = makeRateLimitCleanupTimer();
841
+ rateLimitCleanupTimer.unref();
842
+ }
843
+ }
668
844
  async function validateAuth(req, apiKeys, auth0Enabled) {
669
845
  // Try static API key first (fastest — for admin/server keys)
670
846
  if (apiKeys && validateApiKey(req, apiKeys)) {
@@ -673,10 +849,15 @@ async function validateAuth(req, apiKeys, auth0Enabled) {
673
849
  // Try cbk_ account key (validates against CMS)
674
850
  const rawKey = extractApiKey(req);
675
851
  if (rawKey?.startsWith("cbk_")) {
676
- const tier = await resolveApiKeyTier(rawKey);
677
- if (tier)
852
+ const verdict = await validateAccountKey(rawKey);
853
+ if (verdict.valid)
678
854
  return { valid: true };
679
- return { valid: false, reason: "Invalid or expired API key" };
855
+ return {
856
+ valid: false,
857
+ reason: verdict.reason === "backend_unreachable"
858
+ ? "Authentication backend is unavailable — try again shortly."
859
+ : "Invalid or expired API key",
860
+ };
680
861
  }
681
862
  // Try Auth0 / OAuth token (includes tokens from our /authorize flow)
682
863
  const authHeader = req.headers.authorization;
@@ -684,10 +865,15 @@ async function validateAuth(req, apiKeys, auth0Enabled) {
684
865
  const token = authHeader.slice(7);
685
866
  // Check if it's a cbk_ key returned by our OAuth flow
686
867
  if (token.startsWith("cbk_")) {
687
- const tier = await resolveApiKeyTier(token);
688
- if (tier)
868
+ const verdict = await validateAccountKey(token);
869
+ if (verdict.valid)
689
870
  return { valid: true };
690
- return { valid: false, reason: "Invalid or expired API key" };
871
+ return {
872
+ valid: false,
873
+ reason: verdict.reason === "backend_unreachable"
874
+ ? "Authentication backend is unavailable — try again shortly."
875
+ : "Invalid or expired API key",
876
+ };
691
877
  }
692
878
  // Try Auth0 if enabled
693
879
  if (auth0Enabled) {
@@ -878,10 +1064,28 @@ async function handleMcpRequest(req, res, transport) {
878
1064
  }
879
1065
  // Parse body for POST requests
880
1066
  if (req.method === "POST") {
1067
+ // Cap the body. This read happens BEFORE authentication, so an unauthenticated
1068
+ // POST to /mcp with a multi-gigabyte body was buffered into memory in full
1069
+ // before anything looked at its size — and after the auth fix it is the path
1070
+ // every REJECTED key walks down, which makes it the cheapest way to hurt the
1071
+ // box. 4MB is far above any real JSON-RPC payload. (2026-07-26)
881
1072
  const chunks = [];
1073
+ let received = 0;
1074
+ let tooLarge = false;
882
1075
  for await (const chunk of req) {
1076
+ received += chunk.length;
1077
+ if (received > MAX_REQUEST_BODY_BYTES) {
1078
+ tooLarge = true;
1079
+ break;
1080
+ }
883
1081
  chunks.push(chunk);
884
1082
  }
1083
+ if (tooLarge) {
1084
+ req.destroy();
1085
+ res.writeHead(413, { "Content-Type": "application/json" });
1086
+ res.end(JSON.stringify({ error: "Payload Too Large", message: `Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.` }));
1087
+ return;
1088
+ }
885
1089
  const body = Buffer.concat(chunks).toString("utf-8");
886
1090
  // Safely parse JSON - reject malformed requests gracefully
887
1091
  let parsedBody;
@@ -914,6 +1118,25 @@ async function handleMcpRequest(req, res, transport) {
914
1118
  const targetUrl = (toolArgs?.url || (Array.isArray(toolArgs?.sites) ? toolArgs.sites[0] : "") || "");
915
1119
  // Derived from THIS request, not from the module global — see resolveRequestKeyHash.
916
1120
  const keyHash = await resolveRequestKeyHash(req);
1121
+ // GATE BEFORE CHARGE. The CMS credit catalog and the package's tier catalog
1122
+ // are two independent lists, and they disagree: CMS lists
1123
+ // agent_ready_audit as a free-tier tool costing 5 credits, while the
1124
+ // package gates it as "pro". A free account with credits was therefore
1125
+ // debited 5 and then handed the upgrade prompt that createGatedServer had
1126
+ // substituted for the handler — paying for an advertisement. Checking
1127
+ // accessibility BEFORE the deduct fixes every present and future
1128
+ // disagreement between the two lists, not just the one instance we found.
1129
+ // (2026-07-26)
1130
+ if (keyHash && !isToolAccessible(toolName)) {
1131
+ console.log(`[Credits] ${toolName}: unavailable at this tier — upgrade prompt returned WITHOUT charging`);
1132
+ res.writeHead(200, { "Content-Type": "application/json" });
1133
+ res.end(JSON.stringify({
1134
+ jsonrpc: "2.0",
1135
+ id: parsedBody?.id ?? null,
1136
+ result: upgradePrompt(toolName),
1137
+ }));
1138
+ return;
1139
+ }
917
1140
  if (keyHash) {
918
1141
  const cmsUrl = process.env.CMS_URL || "http://localhost:3200";
919
1142
  const deductParams = new URLSearchParams({
@@ -928,8 +1151,20 @@ async function handleMcpRequest(req, res, transport) {
928
1151
  // Omitting it made the CMS return 403 → parsed as {allowed: undefined} →
929
1152
  // every tool call denied ("Tool execution denied", reason undefined).
930
1153
  headers: { "X-Internal-Secret": process.env.CMS_INTERNAL_SECRET || "" },
1154
+ // Without this, a CMS that accepts the connection and never answers
1155
+ // hangs the MCP request forever, before the handler is ever reached.
1156
+ signal: AbortSignal.timeout(CREDIT_DEDUCT_TIMEOUT_MS),
931
1157
  });
932
- if (creditRes.ok || creditRes.status === 402 || creditRes.status === 403) {
1158
+ // Every status must land in a branch. Previously anything outside
1159
+ // {ok, 402, 403} — a 500, or an nginx 502 during a CMS restart — fell
1160
+ // straight through to execution with NO LOG LINE AT ALL, because the
1161
+ // deliberate loud fail-open message below only fires on a THROWN fetch.
1162
+ // That is the most likely outage shape, and it was the one shape that
1163
+ // stayed invisible, which defeats the point of the loud message.
1164
+ if (!(creditRes.ok || creditRes.status === 402 || creditRes.status === 403)) {
1165
+ console.warn(`[Credits] FAIL-OPEN: ${toolName} ran unbilled — CMS returned HTTP ${creditRes.status}`);
1166
+ }
1167
+ else if (creditRes.ok || creditRes.status === 402 || creditRes.status === 403) {
933
1168
  const data = await creditRes.json();
934
1169
  // Only deny on an EXPLICIT allowed:false from the deduct endpoint.
935
1170
  // A missing `allowed` field means the response was not a real deduct
@@ -1033,6 +1268,10 @@ async function handleMcpRequest(req, res, transport) {
1033
1268
  export async function startRemoteMcpServer(options) {
1034
1269
  // Auto-initialize all data directories on server start
1035
1270
  ensureDirectories();
1271
+ // Session reaper + rate-limit sweeper. Started HERE rather than at module
1272
+ // import, so loading this file no longer spins up a Chrome-killing reaper in
1273
+ // whatever process happened to import it. See startBackgroundTimers.
1274
+ startBackgroundTimers();
1036
1275
  // Enable remote mode for screenshot handling (returns base64 images instead of paths)
1037
1276
  setRemoteMode(true);
1038
1277
  const port = parseInt(process.env.PORT || "3000", 10);
@@ -1788,51 +2027,81 @@ ${VERSION}
1788
2027
  res.end(JSON.stringify({ error: "Not found" }));
1789
2028
  });
1790
2029
  httpServer.listen(port, host, () => {
1791
- console.log(`\nCognitive Browser Remote MCP Server running at http://${host}:${port}`);
1792
- console.log(`\nEndpoints:`);
1793
- console.log(` MCP: http://${host}:${port}/mcp`);
1794
- console.log(` Health: http://${host}:${port}/health`);
1795
- console.log(` Info: http://${host}:${port}/info`);
1796
- if (auth0Enabled) {
1797
- console.log(` OAuth: http://${host}:${port}/.well-known/oauth-protected-resource`);
1798
- }
1799
- if (authEnabled) {
1800
- console.log(`\nAuthentication:`);
1801
- if (apiKeyAuthEnabled) {
1802
- console.log(` API Key: Authorization: Bearer <your-api-key>`);
1803
- console.log(` X-API-Key: <your-api-key>`);
2030
+ void (async () => {
2031
+ console.log(`\nCognitive Browser Remote MCP Server running at http://${host}:${port}`);
2032
+ console.log(`\nEndpoints:`);
2033
+ console.log(` MCP: http://${host}:${port}/mcp`);
2034
+ console.log(` Health: http://${host}:${port}/health`);
2035
+ console.log(` Info: http://${host}:${port}/info`);
2036
+ if (auth0Enabled) {
2037
+ console.log(` OAuth: http://${host}:${port}/.well-known/oauth-protected-resource`);
1804
2038
  }
2039
+ if (authEnabled) {
2040
+ console.log(`\nAuthentication:`);
2041
+ if (apiKeyAuthEnabled) {
2042
+ console.log(` API Key: Authorization: Bearer <your-api-key>`);
2043
+ console.log(` X-API-Key: <your-api-key>`);
2044
+ }
2045
+ if (auth0Enabled) {
2046
+ console.log(` OAuth: Authorization: Bearer <jwt-token>`);
2047
+ console.log(` Auth0 Domain: ${auth0?.domain}`);
2048
+ }
2049
+ }
2050
+ console.log(`\nFor claude.ai custom connector:`);
2051
+ console.log(` URL: http://${host}:${port}/mcp`);
1805
2052
  if (auth0Enabled) {
1806
- console.log(` OAuth: Authorization: Bearer <jwt-token>`);
1807
- console.log(` Auth0 Domain: ${auth0?.domain}`);
2053
+ console.log(` OAuth metadata: http://${host}:${port}/.well-known/oauth-protected-resource`);
1808
2054
  }
1809
- }
1810
- console.log(`\nFor claude.ai custom connector:`);
1811
- console.log(` URL: http://${host}:${port}/mcp`);
1812
- if (auth0Enabled) {
1813
- console.log(` OAuth metadata: http://${host}:${port}/.well-known/oauth-protected-resource`);
1814
- }
1815
- // Cache tool manifest for unauthenticated tools/list (discovery surface is public).
1816
- // Create a temporary server to extract the registered tool list directly.
1817
- // Tool registration works without a browser — only tool execution needs one.
1818
- try {
1819
- const dummyContext = { getBrowser: () => null };
1820
- const tempServer = createMcpServer(options?.registerTools, "manifest-cache", null);
1821
- const toolMap = tempServer._registeredTools;
1822
- if (toolMap && typeof toolMap === "object") {
1823
- cachedToolManifest = Object.entries(toolMap)
1824
- .filter(([, tool]) => tool.enabled !== false)
1825
- .map(([name, tool]) => ({
1826
- name,
1827
- description: tool.description || "",
1828
- inputSchema: tool.inputSchema || { type: "object" },
1829
- }));
1830
- console.log(`[tools/list] Public manifest cached: ${cachedToolManifest.length} tools`);
2055
+ // Cache tool manifest for unauthenticated tools/list (discovery surface is public).
2056
+ // Create a temporary server to extract the registered tool list directly.
2057
+ // Tool registration works without a browser — only tool execution needs one.
2058
+ try {
2059
+ const dummyContext = { getBrowser: () => null };
2060
+ const tempServer = createMcpServer(options?.registerTools, "manifest-cache", null);
2061
+ // Ask the server for its tool list the way a client does, rather than
2062
+ // reading `_registeredTools` and publishing `tool.inputSchema` directly.
2063
+ //
2064
+ // That internal field holds the ZodRawShape/ZodObject, NOT JSON Schema —
2065
+ // the SDK converts it only while serving tools/list. Publishing it raw put
2066
+ // Zod internals (`_def`, `~standard`, `_cached`) on the wire for every tool
2067
+ // whose schema is a ZodObject: measured 17 of 122 on demo and 14 of 125 on
2068
+ // enterprise. Because this manifest serves the UNAUTHENTICATED tools/list,
2069
+ // that was the first thing any prospective MCP client saw, while
2070
+ // authenticated clients got correct schemas from the SDK's own path — two
2071
+ // sources of truth for one contract, and the public one was the broken one.
2072
+ //
2073
+ // Driving the real handler means the public manifest cannot diverge from
2074
+ // the authenticated one again, because it is now the same code path.
2075
+ // (2026-07-26)
2076
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
2077
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
2078
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
2079
+ const manifestClient = new Client({ name: "manifest-cache", version: "1" });
2080
+ await Promise.all([
2081
+ tempServer.connect(serverTransport),
2082
+ manifestClient.connect(clientTransport),
2083
+ ]);
2084
+ const listed = await manifestClient.listTools();
2085
+ await manifestClient.close();
2086
+ if (listed?.tools?.length) {
2087
+ cachedToolManifest = listed.tools.map((tool) => ({
2088
+ name: tool.name,
2089
+ description: tool.description || "",
2090
+ inputSchema: tool.inputSchema || { type: "object" },
2091
+ }));
2092
+ const malformed = cachedToolManifest.filter((t) => !t.inputSchema || typeof t.inputSchema !== "object"
2093
+ || t.inputSchema.type !== "object");
2094
+ console.log(`[tools/list] Public manifest cached: ${cachedToolManifest.length} tools`);
2095
+ if (malformed.length) {
2096
+ // Loud, because a silently malformed public contract is what this fix replaced.
2097
+ console.error(`[tools/list] WARNING: ${malformed.length} tool(s) have a non-JSON-Schema inputSchema: ${malformed.map((t) => t.name).join(", ")}`);
2098
+ }
2099
+ }
1831
2100
  }
1832
- }
1833
- catch (err) {
1834
- console.log(`[tools/list] Direct cache failed, will cache on first authenticated request`);
1835
- }
2101
+ catch (err) {
2102
+ console.log(`[tools/list] Direct cache failed, will cache on first authenticated request`);
2103
+ }
2104
+ })();
1836
2105
  });
1837
2106
  // Graceful shutdown
1838
2107
  const shutdown = async () => {