cbrowser 18.72.6 → 18.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/selector-cache.d.ts +11 -0
- package/dist/browser/selector-cache.d.ts.map +1 -1
- package/dist/browser/selector-cache.js +41 -4
- package/dist/browser/selector-cache.js.map +1 -1
- package/dist/browser/session-manager.d.ts +10 -3
- package/dist/browser/session-manager.d.ts.map +1 -1
- package/dist/browser/session-manager.js +38 -7
- package/dist/browser/session-manager.js.map +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +28 -4
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +51 -11
- package/dist/cli.js.map +1 -1
- package/dist/mcp-server-remote.d.ts +45 -0
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +323 -54
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +8 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +8 -4
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +0 -2
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/session-tools.js +3 -3
- package/dist/mcp-tools/base/session-tools.js.map +1 -1
- package/dist/mcp-tools/index.d.ts.map +1 -1
- package/dist/mcp-tools/index.js +13 -4
- package/dist/mcp-tools/index.js.map +1 -1
- package/dist/mcp-tools/security-layer.d.ts +27 -0
- package/dist/mcp-tools/security-layer.d.ts.map +1 -0
- package/dist/mcp-tools/security-layer.js +145 -0
- package/dist/mcp-tools/security-layer.js.map +1 -0
- package/dist/output.d.ts +20 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +157 -4
- package/dist/output.js.map +1 -1
- package/dist/site-model/manager.d.ts.map +1 -1
- package/dist/site-model/manager.js +27 -3
- package/dist/site-model/manager.js.map +1 -1
- package/dist/stealth/framework.d.ts.map +1 -1
- package/dist/stealth/framework.js +6 -1
- package/dist/stealth/framework.js.map +1 -1
- package/package.json +6 -3
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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() +
|
|
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
|
|
677
|
-
if (
|
|
852
|
+
const verdict = await validateAccountKey(rawKey);
|
|
853
|
+
if (verdict.valid)
|
|
678
854
|
return { valid: true };
|
|
679
|
-
return {
|
|
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
|
|
688
|
-
if (
|
|
868
|
+
const verdict = await validateAccountKey(token);
|
|
869
|
+
if (verdict.valid)
|
|
689
870
|
return { valid: true };
|
|
690
|
-
return {
|
|
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
|
-
|
|
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
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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:
|
|
1807
|
-
console.log(` Auth0 Domain: ${auth0?.domain}`);
|
|
2053
|
+
console.log(` OAuth metadata: http://${host}:${port}/.well-known/oauth-protected-resource`);
|
|
1808
2054
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
1834
|
-
|
|
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 () => {
|