browser-debug-mcp-bridge 1.10.0 → 1.11.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 (45) hide show
  1. package/README.md +267 -195
  2. package/apps/mcp-server/dist/db/events-repository.js +61 -9
  3. package/apps/mcp-server/dist/db/events-repository.js.map +1 -1
  4. package/apps/mcp-server/dist/db/migrations.js +470 -70
  5. package/apps/mcp-server/dist/db/migrations.js.map +1 -1
  6. package/apps/mcp-server/dist/db/schema.js +134 -1
  7. package/apps/mcp-server/dist/db/schema.js.map +1 -1
  8. package/apps/mcp-server/dist/document-response-rewriter.js +196 -0
  9. package/apps/mcp-server/dist/document-response-rewriter.js.map +1 -0
  10. package/apps/mcp-server/dist/json-rewrite.js +189 -0
  11. package/apps/mcp-server/dist/json-rewrite.js.map +1 -0
  12. package/apps/mcp-server/dist/main.js +339 -2
  13. package/apps/mcp-server/dist/main.js.map +1 -1
  14. package/apps/mcp-server/dist/mcp/server.js +2146 -176
  15. package/apps/mcp-server/dist/mcp/server.js.map +1 -1
  16. package/apps/mcp-server/dist/next-asset-mapper.js +701 -0
  17. package/apps/mcp-server/dist/next-asset-mapper.js.map +1 -0
  18. package/apps/mcp-server/dist/next-source-override-planner.js +601 -0
  19. package/apps/mcp-server/dist/next-source-override-planner.js.map +1 -0
  20. package/apps/mcp-server/dist/override-audit-contract.js +51 -0
  21. package/apps/mcp-server/dist/override-audit-contract.js.map +1 -0
  22. package/apps/mcp-server/dist/override-audit.js +740 -0
  23. package/apps/mcp-server/dist/override-audit.js.map +1 -0
  24. package/apps/mcp-server/dist/override-capabilities.js +136 -0
  25. package/apps/mcp-server/dist/override-capabilities.js.map +1 -0
  26. package/apps/mcp-server/dist/override-observed-assets.js +179 -0
  27. package/apps/mcp-server/dist/override-observed-assets.js.map +1 -0
  28. package/apps/mcp-server/dist/override-poc.js +336 -0
  29. package/apps/mcp-server/dist/override-poc.js.map +1 -0
  30. package/apps/mcp-server/dist/override-profile-generator.js +403 -0
  31. package/apps/mcp-server/dist/override-profile-generator.js.map +1 -0
  32. package/apps/mcp-server/dist/override-response-planner.js +557 -0
  33. package/apps/mcp-server/dist/override-response-planner.js.map +1 -0
  34. package/apps/mcp-server/dist/override-rule-types.js +32 -0
  35. package/apps/mcp-server/dist/override-rule-types.js.map +1 -0
  36. package/apps/mcp-server/dist/retention.js +4 -3
  37. package/apps/mcp-server/dist/retention.js.map +1 -1
  38. package/apps/mcp-server/dist/rsc-flight-patch-safety.js +269 -0
  39. package/apps/mcp-server/dist/rsc-flight-patch-safety.js.map +1 -0
  40. package/apps/mcp-server/dist/websocket/messages.js +5 -0
  41. package/apps/mcp-server/dist/websocket/messages.js.map +1 -1
  42. package/apps/mcp-server/dist/websocket/websocket-server.js +10 -0
  43. package/apps/mcp-server/dist/websocket/websocket-server.js.map +1 -1
  44. package/apps/mcp-server/package.json +1 -0
  45. package/package.json +12 -1
@@ -1,10 +1,20 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
- import { existsSync, readFileSync } from 'fs';
4
+ import { createHash, randomUUID } from 'crypto';
5
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
5
6
  import { dirname, resolve } from 'path';
6
7
  import { z } from 'zod';
7
8
  import { getConnection } from '../db/connection.js';
9
+ import { diagnoseOverridePoc, insertOverridePlanAudit, listOverridePlanAudits, listOverridePocRequests, listOverridePocRuns, } from '../override-audit.js';
10
+ import { createOverrideProfileConfig, OVERRIDE_PROFILE_ADAPTERS, } from '../override-profile-generator.js';
11
+ import { assertOverrideResponseRequestCaptureSafe, classifyOverrideResponseRequestCapability, } from '../override-capabilities.js';
12
+ import { getOverridePocConfigSummary } from '../override-poc.js';
13
+ import { normalizeOverrideRequestMethod } from '../override-rule-types.js';
14
+ import { mapNextOverrideAssetsWithDrift } from '../next-asset-mapper.js';
15
+ import { planNextSourceOverride } from '../next-source-override-planner.js';
16
+ import { listObservedOverrideAssets, persistObservedOverrideAssets } from '../override-observed-assets.js';
17
+ import { planOverrideResponsePatch } from '../override-response-planner.js';
8
18
  function createDefaultMcpLogger() {
9
19
  const write = (level, message, payload) => {
10
20
  process.stderr.write(`${message} ${JSON.stringify({ level, ...payload })}\n`);
@@ -270,6 +280,13 @@ const TOOL_SCHEMAS = {
270
280
  sessionId: { type: 'string' },
271
281
  },
272
282
  },
283
+ get_live_session_health: {
284
+ type: 'object',
285
+ required: ['sessionId'],
286
+ properties: {
287
+ sessionId: { type: 'string' },
288
+ },
289
+ },
273
290
  get_recent_events: {
274
291
  type: 'object',
275
292
  properties: {
@@ -470,13 +487,6 @@ const TOOL_SCHEMAS = {
470
487
  maxTextLength: { type: 'number' },
471
488
  },
472
489
  },
473
- get_live_session_health: {
474
- type: 'object',
475
- required: ['sessionId'],
476
- properties: {
477
- sessionId: { type: 'string' },
478
- },
479
- },
480
490
  set_viewport: {
481
491
  type: 'object',
482
492
  required: ['sessionId', 'width', 'height'],
@@ -577,6 +587,223 @@ const TOOL_SCHEMAS = {
577
587
  maxResponseBytes: { type: 'number' },
578
588
  },
579
589
  },
590
+ list_override_profiles: {
591
+ type: 'object',
592
+ properties: {},
593
+ },
594
+ create_override_profile: {
595
+ type: 'object',
596
+ required: ['targetBaseUrl'],
597
+ properties: {
598
+ adapter: { type: 'string' },
599
+ mode: { type: 'string' },
600
+ targetBaseUrl: { type: 'string' },
601
+ projectRoot: { type: 'string' },
602
+ assetRoot: { type: 'string' },
603
+ nextDir: { type: 'string' },
604
+ configPath: { type: 'string' },
605
+ profileId: { type: 'string' },
606
+ profileName: { type: 'string' },
607
+ enabled: { type: 'boolean' },
608
+ profileEnabled: { type: 'boolean' },
609
+ autoReload: { type: 'boolean' },
610
+ includeManifestFiles: { type: 'boolean' },
611
+ includeStaticFiles: { type: 'boolean' },
612
+ extensions: { type: 'array', items: { type: 'string' } },
613
+ maxRules: { type: 'number' },
614
+ writeConfig: { type: 'boolean' },
615
+ overwrite: { type: 'boolean' },
616
+ },
617
+ },
618
+ validate_override_profile: {
619
+ type: 'object',
620
+ properties: {
621
+ profileId: { type: 'string' },
622
+ },
623
+ },
624
+ preflight_overrides: {
625
+ type: 'object',
626
+ required: ['sessionId'],
627
+ properties: {
628
+ sessionId: { type: 'string' },
629
+ profileId: { type: 'string' },
630
+ },
631
+ },
632
+ observe_override_assets: {
633
+ type: 'object',
634
+ required: ['sessionId'],
635
+ properties: {
636
+ sessionId: { type: 'string' },
637
+ tabId: { type: 'number' },
638
+ includePerformance: { type: 'boolean' },
639
+ },
640
+ },
641
+ capture_override_response_body: {
642
+ type: 'object',
643
+ required: ['sessionId'],
644
+ properties: {
645
+ sessionId: { type: 'string' },
646
+ tabId: { type: 'number' },
647
+ targetUrl: { type: 'string' },
648
+ targetAssetUrl: { type: 'string' },
649
+ captureMode: { type: 'string', enum: ['extension-fetch', 'cdp-response'] },
650
+ triggerReload: { type: 'boolean' },
651
+ matchMode: { type: 'string', enum: ['exact', 'prefix'] },
652
+ requestMethod: { type: 'string' },
653
+ requestHeaders: { type: 'object' },
654
+ timeoutMs: { type: 'number' },
655
+ maxBodyBytes: { type: 'number' },
656
+ includeBody: { type: 'boolean' },
657
+ },
658
+ },
659
+ list_observed_override_assets: {
660
+ type: 'object',
661
+ required: ['sessionId'],
662
+ properties: {
663
+ sessionId: { type: 'string' },
664
+ limit: { type: 'number' },
665
+ sinceTimestamp: { type: 'number' },
666
+ },
667
+ },
668
+ map_next_override_assets: {
669
+ type: 'object',
670
+ required: ['projectRoot'],
671
+ properties: {
672
+ sessionId: { type: 'string' },
673
+ tabId: { type: 'number' },
674
+ projectRoot: { type: 'string' },
675
+ nextDir: { type: 'string' },
676
+ route: { type: 'string' },
677
+ sourcePaths: { type: 'array', items: { type: 'string' } },
678
+ observedAssets: { type: 'array', items: { type: 'object' } },
679
+ maxResults: { type: 'number' },
680
+ fetchProductionAssets: { type: 'boolean' },
681
+ productionFetchTimeoutMs: { type: 'number' },
682
+ maxProductionAssetBytes: { type: 'number' },
683
+ maxDriftCandidates: { type: 'number' },
684
+ productionFetchConcurrency: { type: 'number' },
685
+ },
686
+ },
687
+ plan_override_response_patch: {
688
+ type: 'object',
689
+ properties: {
690
+ sessionId: { type: 'string' },
691
+ tabId: { type: 'number' },
692
+ targetUrl: { type: 'string' },
693
+ targetAssetUrl: { type: 'string' },
694
+ captureMode: { type: 'string', enum: ['extension-fetch', 'cdp-response'] },
695
+ triggerReload: { type: 'boolean' },
696
+ ruleType: { type: 'string' },
697
+ requestMethod: { type: 'string' },
698
+ matchMode: { type: 'string', enum: ['exact', 'prefix'] },
699
+ requestHeaders: { type: 'object' },
700
+ timeoutMs: { type: 'number' },
701
+ contentType: { type: 'string' },
702
+ responseBodyText: { type: 'string' },
703
+ bodyText: { type: 'string' },
704
+ responseBodyBase64: { type: 'string' },
705
+ bodyBase64: { type: 'string' },
706
+ textPatches: { type: 'array', items: { type: 'object' } },
707
+ jsonPatches: { type: 'array', items: { type: 'object' } },
708
+ documentPatches: { type: 'array', items: { type: 'object' } },
709
+ maxBodyBytes: { type: 'number' },
710
+ outputRoot: { type: 'string' },
711
+ configPath: { type: 'string' },
712
+ writeBody: { type: 'boolean' },
713
+ writeConfig: { type: 'boolean' },
714
+ overwrite: { type: 'boolean' },
715
+ enabled: { type: 'boolean' },
716
+ profileEnabled: { type: 'boolean' },
717
+ autoReload: { type: 'boolean' },
718
+ profileId: { type: 'string' },
719
+ profileName: { type: 'string' },
720
+ ruleId: { type: 'string' },
721
+ includePreview: { type: 'boolean' },
722
+ },
723
+ },
724
+ plan_next_source_override: {
725
+ type: 'object',
726
+ required: ['projectRoot', 'sourceEdits'],
727
+ properties: {
728
+ sessionId: { type: 'string' },
729
+ tabId: { type: 'number' },
730
+ projectRoot: { type: 'string' },
731
+ nextDir: { type: 'string' },
732
+ route: { type: 'string' },
733
+ sourcePaths: { type: 'array', items: { type: 'string' } },
734
+ sourceEdits: { type: 'array', items: { type: 'object' } },
735
+ observedAssets: { type: 'array', items: { type: 'object' } },
736
+ configPath: { type: 'string' },
737
+ writeConfig: { type: 'boolean' },
738
+ overwrite: { type: 'boolean' },
739
+ enabled: { type: 'boolean' },
740
+ profileEnabled: { type: 'boolean' },
741
+ autoReload: { type: 'boolean' },
742
+ profileId: { type: 'string' },
743
+ profileName: { type: 'string' },
744
+ buildTimeoutMs: { type: 'number' },
745
+ maxRules: { type: 'number' },
746
+ fetchProductionAssets: { type: 'boolean' },
747
+ productionFetchTimeoutMs: { type: 'number' },
748
+ maxProductionAssetBytes: { type: 'number' },
749
+ maxDriftCandidates: { type: 'number' },
750
+ productionFetchConcurrency: { type: 'number' },
751
+ overlayTtlMs: { type: 'number' },
752
+ },
753
+ },
754
+ enable_overrides: {
755
+ type: 'object',
756
+ required: ['sessionId'],
757
+ properties: {
758
+ sessionId: { type: 'string' },
759
+ tabId: { type: 'number' },
760
+ profileId: { type: 'string' },
761
+ },
762
+ },
763
+ disable_overrides: {
764
+ type: 'object',
765
+ required: ['sessionId'],
766
+ properties: {
767
+ sessionId: { type: 'string' },
768
+ },
769
+ },
770
+ get_override_status: {
771
+ type: 'object',
772
+ properties: {
773
+ sessionId: { type: 'string' },
774
+ profileId: { type: 'string' },
775
+ },
776
+ },
777
+ get_override_request_log: {
778
+ type: 'object',
779
+ required: ['sessionId'],
780
+ properties: {
781
+ sessionId: { type: 'string' },
782
+ runId: { type: 'string' },
783
+ limit: { type: 'number' },
784
+ offset: { type: 'number' },
785
+ maxResponseBytes: { type: 'number' },
786
+ },
787
+ },
788
+ get_override_plan_log: {
789
+ type: 'object',
790
+ required: ['sessionId'],
791
+ properties: {
792
+ sessionId: { type: 'string' },
793
+ planId: { type: 'string' },
794
+ limit: { type: 'number' },
795
+ offset: { type: 'number' },
796
+ maxResponseBytes: { type: 'number' },
797
+ },
798
+ },
799
+ diagnose_overrides: {
800
+ type: 'object',
801
+ required: ['sessionId'],
802
+ properties: {
803
+ sessionId: { type: 'string' },
804
+ runId: { type: 'string' },
805
+ },
806
+ },
580
807
  explain_last_failure: {
581
808
  type: 'object',
582
809
  required: ['sessionId'],
@@ -841,6 +1068,22 @@ const TOOL_DESCRIPTIONS = {
841
1068
  wait_for_page_state: 'Poll compact page state until a structured assertion becomes true',
842
1069
  capture_ui_snapshot: 'Capture redacted UI snapshot (DOM/styles/optional PNG) and persist it',
843
1070
  get_live_console_logs: 'Read in-memory live console logs for a connected session',
1071
+ list_override_profiles: 'List configured browser override profiles',
1072
+ create_override_profile: 'Generate a candidate browser override profile from local build assets',
1073
+ validate_override_profile: 'Validate the current browser override profile and local asset readiness',
1074
+ preflight_overrides: 'Run production-safety checks before enabling browser overrides for a live session',
1075
+ observe_override_assets: 'Observe production render artifacts from a live extension tab',
1076
+ capture_override_response_body: 'Capture a bounded text response body from a live extension session for override planning, using extension fetch or explicit CDP response-stage capture',
1077
+ list_observed_override_assets: 'List persisted production render artifacts observed for a session',
1078
+ map_next_override_assets: 'Map observed production Next.js assets to local build chunks and source paths',
1079
+ plan_override_response_patch: 'Patch a supplied or live-captured text response body with literal textPatches or JSON Pointer jsonPatches and write an exact or prefix override rule for supported response types',
1080
+ plan_next_source_override: 'Apply source edits in a temp Next.js overlay build and plan exact browser override rules',
1081
+ enable_overrides: 'Enable browser overrides for a live extension session',
1082
+ disable_overrides: 'Disable browser overrides for a live extension session',
1083
+ get_override_status: 'Read live or persisted browser override status for a session',
1084
+ get_override_request_log: 'Read persisted browser override request audit rows',
1085
+ get_override_plan_log: 'Read persisted generated override plan audit rows with previews, hashes, and rollback metadata',
1086
+ diagnose_overrides: 'Diagnose persisted browser override runs and failure indicators',
844
1087
  explain_last_failure: 'Explain the latest failure timeline',
845
1088
  get_event_correlation: 'Correlate related events by window',
846
1089
  list_snapshots: 'List snapshot metadata by session/time/trigger',
@@ -870,6 +1113,16 @@ const DEFAULT_NETWORK_POLL_TIMEOUT_MS = 15_000;
870
1113
  const MAX_NETWORK_POLL_TIMEOUT_MS = 120_000;
871
1114
  const DEFAULT_NETWORK_POLL_INTERVAL_MS = 250;
872
1115
  const LIVE_SESSION_DISCONNECTED_CODE = 'LIVE_SESSION_DISCONNECTED';
1116
+ const STALE_LIVE_CONNECTION_GRACE_WINDOW_MS = 30 * 60 * 1000;
1117
+ const NOISE_SESSION_HOST_PATTERNS = [
1118
+ /(^|\.)adtrafficquality\.google$/i,
1119
+ /(^|\.)doubleclick\.net$/i,
1120
+ /(^|\.)googlesyndication\.com$/i,
1121
+ /(^|\.)googleadservices\.com$/i,
1122
+ /(^|\.)recaptcha\.net$/i,
1123
+ /(^|\.)gstatic\.com$/i,
1124
+ ];
1125
+ const NOISE_SESSION_PATH_PATTERNS = [/\/sodar/i, /\/recaptcha/i, /runner\.html$/i];
873
1126
  const NETWORK_CALL_SELECT_COLUMNS = `
874
1127
  request_id, session_id, trace_id, tab_id, ts_start, duration_ms, method, url, origin, status, initiator, error_class, response_size_est,
875
1128
  request_content_type, request_body_text, request_body_json, request_body_bytes, request_body_truncated, request_body_chunk_ref,
@@ -1074,7 +1327,772 @@ function resolveLastUrl(payload) {
1074
1327
  return candidate;
1075
1328
  }
1076
1329
  }
1077
- return undefined;
1330
+ return undefined;
1331
+ }
1332
+ function classifySessionUrl(urlValue) {
1333
+ if (!urlValue) {
1334
+ return {
1335
+ kind: 'unknown',
1336
+ note: 'No session URL is available yet.',
1337
+ };
1338
+ }
1339
+ try {
1340
+ const parsed = new URL(urlValue);
1341
+ const host = parsed.hostname.toLowerCase();
1342
+ const pathname = parsed.pathname.toLowerCase();
1343
+ const origin = parsed.origin;
1344
+ const isLocalhost = host === 'localhost' || host === '127.0.0.1';
1345
+ if (NOISE_SESSION_HOST_PATTERNS.some((pattern) => pattern.test(host))
1346
+ || NOISE_SESSION_PATH_PATTERNS.some((pattern) => pattern.test(pathname))) {
1347
+ return {
1348
+ kind: 'likely_iframe_noise',
1349
+ note: 'Last URL looks like third-party iframe/ad traffic rather than the app surface.',
1350
+ origin,
1351
+ host,
1352
+ isLocalhost,
1353
+ };
1354
+ }
1355
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
1356
+ return {
1357
+ kind: 'top_level_page',
1358
+ note: isLocalhost
1359
+ ? 'Last URL looks like a local top-level app page.'
1360
+ : 'Last URL looks like a top-level app page.',
1361
+ origin,
1362
+ host,
1363
+ isLocalhost,
1364
+ };
1365
+ }
1366
+ }
1367
+ catch {
1368
+ return {
1369
+ kind: 'unknown',
1370
+ note: 'Session URL could not be parsed.',
1371
+ };
1372
+ }
1373
+ return {
1374
+ kind: 'unknown',
1375
+ note: 'Session URL does not use an http(s) page origin.',
1376
+ };
1377
+ }
1378
+ function getSessionStatus(row) {
1379
+ if (row.ended_at) {
1380
+ return 'ended';
1381
+ }
1382
+ if (row.paused_at) {
1383
+ return 'paused';
1384
+ }
1385
+ return 'active';
1386
+ }
1387
+ function buildOverrideProfileRecords() {
1388
+ const summary = getOverridePocConfigSummary();
1389
+ return summary.profiles.map((profile) => ({
1390
+ profileId: profile.profileId,
1391
+ name: profile.name,
1392
+ active: profile.profileId === summary.activeProfileId,
1393
+ configEnabled: summary.configEnabled,
1394
+ enabled: profile.enabled,
1395
+ effectiveEnabled: summary.configEnabled && profile.enabled && profile.enabledRuleCount > 0,
1396
+ autoReload: profile.autoReload,
1397
+ configPath: summary.configPath,
1398
+ fileExists: profile.fileExists,
1399
+ ruleCount: profile.ruleCount,
1400
+ enabledRuleCount: profile.enabledRuleCount,
1401
+ rules: profile.rules,
1402
+ }));
1403
+ }
1404
+ function resolveOverrideProfileRecord(value) {
1405
+ const profiles = buildOverrideProfileRecords();
1406
+ const fallbackProfileId = typeof profiles[0]?.profileId === 'string' ? profiles[0].profileId : 'poc';
1407
+ const requestedProfileId = typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallbackProfileId;
1408
+ const profile = profiles.find((candidate) => candidate.profileId === requestedProfileId);
1409
+ if (!profile) {
1410
+ throw new Error(`Unknown override profile: ${requestedProfileId}`);
1411
+ }
1412
+ return profile;
1413
+ }
1414
+ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
1415
+ function sha256Text(value) {
1416
+ return createHash('sha256').update(value, 'utf8').digest('hex');
1417
+ }
1418
+ function isRecordWithRscFlightMetadata(value) {
1419
+ return isRecord(value)
1420
+ && (value.productionMode === 'structured-flight-v1' && value.patchKind === 'string-value-text'
1421
+ || value.productionMode === 'literal-response-v1' && value.patchKind === 'literal-text')
1422
+ && value.source !== undefined
1423
+ && value.patchKind !== undefined;
1424
+ }
1425
+ function buildRscFlightRuleIssues(rule) {
1426
+ const ruleId = String(rule.ruleId ?? 'unknown');
1427
+ const issues = [];
1428
+ const rscFlight = rule.rscFlight;
1429
+ if (!isRecordWithRscFlightMetadata(rscFlight)) {
1430
+ return [{
1431
+ code: 'UNSUPPORTED_RSC_FLIGHT_RULE',
1432
+ severity: 'error',
1433
+ message: `Rule ${ruleId} targets a Next.js RSC flight response without production RSC metadata generated by the response planner.`,
1434
+ }];
1435
+ }
1436
+ const source = rscFlight.source;
1437
+ if (source !== 'cdp-response' && source !== 'extension-fetch') {
1438
+ issues.push({
1439
+ code: 'RSC_FLIGHT_METADATA_INVALID',
1440
+ severity: 'error',
1441
+ message: `Rule ${ruleId} RSC metadata source must be cdp-response or extension-fetch.`,
1442
+ });
1443
+ }
1444
+ if (!Array.isArray(rscFlight.textPatches) || rscFlight.textPatches.length === 0) {
1445
+ issues.push({
1446
+ code: 'RSC_FLIGHT_PATCHES_INVALID',
1447
+ severity: 'error',
1448
+ message: `Rule ${ruleId} RSC flight metadata must include string-value text patches.`,
1449
+ });
1450
+ }
1451
+ else {
1452
+ for (const [index, patch] of rscFlight.textPatches.entries()) {
1453
+ if (!isRecord(patch)
1454
+ || typeof patch.search !== 'string'
1455
+ || patch.search.length === 0
1456
+ || typeof patch.replacement !== 'string'
1457
+ || typeof patch.expectedCount !== 'number'
1458
+ || !Number.isFinite(patch.expectedCount)
1459
+ || patch.expectedCount < 0) {
1460
+ issues.push({
1461
+ code: 'RSC_FLIGHT_PATCHES_INVALID',
1462
+ severity: 'error',
1463
+ message: `Rule ${ruleId} RSC flight textPatches[${index}] is invalid.`,
1464
+ });
1465
+ }
1466
+ }
1467
+ }
1468
+ if (rule.requestMethod !== 'GET') {
1469
+ issues.push({
1470
+ code: 'RSC_FLIGHT_METHOD_UNSUPPORTED',
1471
+ severity: 'error',
1472
+ message: `Rule ${ruleId} RSC flight overrides only support GET requests.`,
1473
+ });
1474
+ }
1475
+ const targetAssetUrl = typeof rule.targetAssetUrl === 'string' ? rule.targetAssetUrl : '';
1476
+ try {
1477
+ const parsed = new URL(targetAssetUrl);
1478
+ if (!parsed.searchParams.has('_rsc')) {
1479
+ issues.push({
1480
+ code: 'RSC_FLIGHT_TARGET_INVALID',
1481
+ severity: 'error',
1482
+ message: `Rule ${ruleId} RSC flight targetAssetUrl must include the _rsc search parameter.`,
1483
+ });
1484
+ }
1485
+ }
1486
+ catch {
1487
+ issues.push({
1488
+ code: 'RSC_FLIGHT_TARGET_INVALID',
1489
+ severity: 'error',
1490
+ message: `Rule ${ruleId} RSC flight targetAssetUrl must be an absolute http(s) URL.`,
1491
+ });
1492
+ }
1493
+ const contentType = typeof rule.contentType === 'string' ? rule.contentType : '';
1494
+ const metadataContentType = typeof rscFlight.contentType === 'string' ? rscFlight.contentType : '';
1495
+ if (!contentType.toLowerCase().includes('text/x-component') || !metadataContentType.toLowerCase().includes('text/x-component')) {
1496
+ issues.push({
1497
+ code: 'RSC_FLIGHT_CONTENT_TYPE_INVALID',
1498
+ severity: 'error',
1499
+ message: `Rule ${ruleId} RSC flight overrides require text/x-component content types.`,
1500
+ });
1501
+ }
1502
+ const originalSha256 = typeof rscFlight.originalSha256 === 'string' ? rscFlight.originalSha256 : '';
1503
+ const patchedSha256 = typeof rscFlight.patchedSha256 === 'string' ? rscFlight.patchedSha256 : '';
1504
+ if (!SHA256_HEX_PATTERN.test(originalSha256) || !SHA256_HEX_PATTERN.test(patchedSha256) || originalSha256 === patchedSha256) {
1505
+ issues.push({
1506
+ code: 'RSC_FLIGHT_HASH_INVALID',
1507
+ severity: 'error',
1508
+ message: `Rule ${ruleId} RSC flight metadata must include distinct original and patched sha256 hashes.`,
1509
+ });
1510
+ }
1511
+ const patchedBytes = typeof rscFlight.patchedBytes === 'number' && Number.isFinite(rscFlight.patchedBytes)
1512
+ ? Math.floor(rscFlight.patchedBytes)
1513
+ : null;
1514
+ if (patchedBytes === null || patchedBytes < 1) {
1515
+ issues.push({
1516
+ code: 'RSC_FLIGHT_BYTES_INVALID',
1517
+ severity: 'error',
1518
+ message: `Rule ${ruleId} RSC flight metadata must include a positive patchedBytes value.`,
1519
+ });
1520
+ }
1521
+ const fileSizeBytes = typeof rule.fileSizeBytes === 'number' && Number.isFinite(rule.fileSizeBytes)
1522
+ ? Math.floor(rule.fileSizeBytes)
1523
+ : null;
1524
+ if (patchedBytes !== null && fileSizeBytes !== null && patchedBytes !== fileSizeBytes) {
1525
+ issues.push({
1526
+ code: 'RSC_FLIGHT_LOCAL_FILE_MISMATCH',
1527
+ severity: 'error',
1528
+ message: `Rule ${ruleId} local RSC file size does not match patchedBytes metadata.`,
1529
+ });
1530
+ }
1531
+ const resolvedLocalFilePath = typeof rule.resolvedLocalFilePath === 'string' ? rule.resolvedLocalFilePath : '';
1532
+ if (resolvedLocalFilePath && existsSync(resolvedLocalFilePath) && SHA256_HEX_PATTERN.test(patchedSha256)) {
1533
+ const body = readFileSync(resolvedLocalFilePath, 'utf8');
1534
+ if (!/(^|\n)\d+:/u.test(body)) {
1535
+ issues.push({
1536
+ code: 'RSC_FLIGHT_BODY_INVALID',
1537
+ severity: 'error',
1538
+ message: `Rule ${ruleId} local RSC file does not match the supported Flight payload shape.`,
1539
+ });
1540
+ }
1541
+ if (sha256Text(body) !== patchedSha256) {
1542
+ issues.push({
1543
+ code: 'RSC_FLIGHT_LOCAL_FILE_MISMATCH',
1544
+ severity: 'error',
1545
+ message: `Rule ${ruleId} local RSC file hash does not match patchedSha256 metadata.`,
1546
+ });
1547
+ }
1548
+ }
1549
+ return issues;
1550
+ }
1551
+ function buildOverrideProfileIssues(profile) {
1552
+ const issues = [];
1553
+ const rules = Array.isArray(profile.rules)
1554
+ ? profile.rules.filter((rule) => isRecord(rule))
1555
+ : [];
1556
+ if (profile.configEnabled !== true) {
1557
+ issues.push({
1558
+ code: 'CONFIG_DISABLED',
1559
+ severity: 'warning',
1560
+ message: 'The override config is disabled and cannot replace requests until enabled.',
1561
+ });
1562
+ }
1563
+ if (profile.enabled !== true) {
1564
+ issues.push({
1565
+ code: 'PROFILE_DISABLED',
1566
+ severity: 'warning',
1567
+ message: 'The override profile is disabled and cannot replace requests until enabled.',
1568
+ });
1569
+ }
1570
+ if (rules.length === 0 || !rules.some((rule) => rule.enabled === true)) {
1571
+ issues.push({
1572
+ code: 'NO_ENABLED_RULES',
1573
+ severity: 'error',
1574
+ message: 'The override profile has no enabled rules.',
1575
+ });
1576
+ }
1577
+ for (const rule of rules) {
1578
+ if (rule.enabled !== true) {
1579
+ continue;
1580
+ }
1581
+ if (typeof rule.targetAssetUrl !== 'string' || !rule.targetAssetUrl.startsWith('http')) {
1582
+ issues.push({
1583
+ code: 'TARGET_URL_INVALID',
1584
+ severity: 'error',
1585
+ message: `Rule ${String(rule.ruleId ?? 'unknown')} targetAssetUrl must be an absolute http(s) URL.`,
1586
+ });
1587
+ }
1588
+ if (rule.fileExists !== true) {
1589
+ issues.push({
1590
+ code: 'LOCAL_FILE_MISSING',
1591
+ severity: 'error',
1592
+ message: `Rule ${String(rule.ruleId ?? 'unknown')} local override file does not exist.`,
1593
+ });
1594
+ }
1595
+ issues.push(...classifyOverrideResponseRequestCapability({
1596
+ ruleId: rule.ruleId,
1597
+ requestMethod: rule.requestMethod,
1598
+ requestHeaders: rule.requestHeaders,
1599
+ ruleType: rule.ruleType,
1600
+ }).issues.map((issue) => ({ ...issue })));
1601
+ if (rule.ruleType === 'rsc-flight') {
1602
+ issues.push(...buildRscFlightRuleIssues(rule));
1603
+ }
1604
+ }
1605
+ return issues;
1606
+ }
1607
+ function buildOverrideProfileNextActions(profile, issues) {
1608
+ if (issues.some((issue) => issue.code === 'SERVER_ACTION_UNSUPPORTED')) {
1609
+ return [{
1610
+ code: 'REPLAN_SERVER_ACTION_OVERRIDE',
1611
+ message: 'Server actions stay unsupported in production override mode; replace the flow with a GET document/data/API response path instead.',
1612
+ }];
1613
+ }
1614
+ if (issues.some((issue) => issue.code === 'MUTATION_REPLAY_UNSUPPORTED')) {
1615
+ return [{
1616
+ code: 'REPLAN_MUTATION_OVERRIDE',
1617
+ message: 'Mutation responses are not replay-safe; move the override to a GET document/data/API response or remove the non-GET rule.',
1618
+ }];
1619
+ }
1620
+ if (issues.some((issue) => issue.code === 'UNSAFE_REQUEST_METHOD')) {
1621
+ return [{
1622
+ code: 'REPLAN_GET_ONLY_OVERRIDE',
1623
+ message: 'Response override rules are production-safe only for GET requests; regenerate or remove non-GET rules.',
1624
+ }];
1625
+ }
1626
+ if (issues.some((issue) => issue.code === 'LOCAL_FILE_MISSING')) {
1627
+ return [{
1628
+ code: 'REBUILD_OR_FIX_LOCAL_PATHS',
1629
+ message: 'Rebuild the local app or fix localFilePath values before enabling overrides.',
1630
+ }];
1631
+ }
1632
+ if (issues.some((issue) => issue.code === 'NO_ENABLED_RULES')) {
1633
+ return [{
1634
+ code: 'ENABLE_RULES',
1635
+ message: 'Enable at least one rule in the selected override profile.',
1636
+ }];
1637
+ }
1638
+ if (issues.some((issue) => issue.code === 'TARGET_URL_INVALID')) {
1639
+ return [{
1640
+ code: 'FIX_TARGET_URLS',
1641
+ message: 'Use absolute http(s) production URLs for every targetAssetUrl.',
1642
+ }];
1643
+ }
1644
+ if (issues.some((issue) => typeof issue.code === 'string' && issue.code.startsWith('RSC_FLIGHT_'))
1645
+ || issues.some((issue) => issue.code === 'UNSUPPORTED_RSC_FLIGHT_RULE')) {
1646
+ return [{
1647
+ code: 'REPLAN_RSC_RESPONSE_OVERRIDE',
1648
+ message: 'Regenerate the RSC rule with plan_override_response_patch from a captured text/x-component response body.',
1649
+ }];
1650
+ }
1651
+ if (profile.configEnabled !== true) {
1652
+ return [{
1653
+ code: 'ENABLE_CONFIG',
1654
+ message: 'Set the root override config enabled=true after reviewing the profile.',
1655
+ }];
1656
+ }
1657
+ if (profile.enabled !== true) {
1658
+ return [{
1659
+ code: 'ENABLE_PROFILE',
1660
+ message: 'Set the selected override profile enabled=true after reviewing its rules.',
1661
+ }];
1662
+ }
1663
+ return [{
1664
+ code: 'ENABLE_OVERRIDES',
1665
+ message: 'Enable overrides on a connected session, then reload the target tab if needed.',
1666
+ }];
1667
+ }
1668
+ function hasEnabledExperimentalRscFlightRule(profile) {
1669
+ const rules = Array.isArray(profile.rules)
1670
+ ? profile.rules.filter((rule) => isRecord(rule))
1671
+ : [];
1672
+ return rules.some((rule) => {
1673
+ return rule.enabled === true
1674
+ && rule.ruleType === 'rsc-flight'
1675
+ && rule.allowExperimentalRscFlightFulfillment === true;
1676
+ });
1677
+ }
1678
+ function canBypassPreflightForExperimentalRsc(profile, blockingCodes) {
1679
+ return blockingCodes.length > 0
1680
+ && blockingCodes.every((code) => code === 'UNSUPPORTED_RSC_FLIGHT_RULE')
1681
+ && hasEnabledExperimentalRscFlightRule(profile);
1682
+ }
1683
+ const OVERRIDE_VARIANT_HEADER_ALLOWLIST = new Set([
1684
+ 'accept',
1685
+ 'content-type',
1686
+ 'next-router-prefetch',
1687
+ 'next-router-state-tree',
1688
+ 'purpose',
1689
+ 'rsc',
1690
+ 'x-nextjs-data',
1691
+ ]);
1692
+ function normalizeOverrideVariantHeaders(value) {
1693
+ if (!isRecord(value)) {
1694
+ return {};
1695
+ }
1696
+ const normalized = {};
1697
+ for (const [rawName, rawValue] of Object.entries(value)) {
1698
+ const name = rawName.trim().toLowerCase();
1699
+ if (!OVERRIDE_VARIANT_HEADER_ALLOWLIST.has(name)) {
1700
+ continue;
1701
+ }
1702
+ if (typeof rawValue === 'string' && rawValue.trim().length > 0) {
1703
+ normalized[name] = rawValue.trim();
1704
+ continue;
1705
+ }
1706
+ if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
1707
+ normalized[name] = String(rawValue);
1708
+ }
1709
+ }
1710
+ return normalized;
1711
+ }
1712
+ function buildOverrideVariantContext(options) {
1713
+ const targetUrl = normalizeOptionalString(options.targetUrl);
1714
+ if (!targetUrl) {
1715
+ return null;
1716
+ }
1717
+ const requestMethod = normalizeOverrideRequestMethod(options.requestMethod);
1718
+ const matchMode = normalizeOptionalString(options.matchMode) ?? 'exact';
1719
+ const ruleType = normalizeOptionalString(options.ruleType) ?? 'document';
1720
+ const captureMode = normalizeOptionalString(options.captureMode);
1721
+ const source = normalizeOptionalString(options.source);
1722
+ const headers = normalizeOverrideVariantHeaders(options.requestHeaders);
1723
+ const isPrefetchVariant = headers['next-router-prefetch'] === '1'
1724
+ || headers.purpose?.toLowerCase() === 'prefetch';
1725
+ const isRscRequest = ruleType === 'rsc-flight' || headers.rsc === '1';
1726
+ let isNextDataRequest = ruleType === 'next-data' || headers['x-nextjs-data'] === '1';
1727
+ let origin;
1728
+ let pathname;
1729
+ let searchParams = [];
1730
+ try {
1731
+ const parsed = new URL(targetUrl);
1732
+ origin = parsed.origin;
1733
+ pathname = parsed.pathname;
1734
+ searchParams = Array.from(parsed.searchParams.entries()).map(([name, value]) => ({ name, value }));
1735
+ if (pathname.startsWith('/_next/data/')) {
1736
+ isNextDataRequest = true;
1737
+ }
1738
+ }
1739
+ catch {
1740
+ pathname = undefined;
1741
+ }
1742
+ const searchParamKeys = [...new Set(searchParams.map((entry) => entry.name))].sort();
1743
+ const variantBasis = {
1744
+ targetUrl,
1745
+ origin: origin ?? null,
1746
+ pathname: pathname ?? null,
1747
+ searchParams,
1748
+ requestMethod,
1749
+ matchMode,
1750
+ ruleType,
1751
+ captureMode: captureMode ?? null,
1752
+ source: source ?? null,
1753
+ triggerReload: options.triggerReload === true,
1754
+ headers,
1755
+ isPrefetchVariant,
1756
+ isRscRequest,
1757
+ isNextDataRequest,
1758
+ };
1759
+ return {
1760
+ ...variantBasis,
1761
+ searchParamKeys,
1762
+ variantKey: sha256Text(JSON.stringify(variantBasis)),
1763
+ };
1764
+ }
1765
+ function extractPlanVariantContext(plan) {
1766
+ if (isRecord(plan.patchSummary) && isRecord(plan.patchSummary.variantContext)) {
1767
+ return plan.patchSummary.variantContext;
1768
+ }
1769
+ if (isRecord(plan.capturedFromLiveSession)) {
1770
+ if (isRecord(plan.capturedFromLiveSession.variantContext)) {
1771
+ return plan.capturedFromLiveSession.variantContext;
1772
+ }
1773
+ return buildOverrideVariantContext({
1774
+ targetUrl: plan.capturedFromLiveSession.targetUrl ?? plan.targetAssetUrl,
1775
+ requestMethod: plan.capturedFromLiveSession.requestMethod ?? plan.requestMethod,
1776
+ matchMode: plan.capturedFromLiveSession.matchMode ?? plan.matchMode,
1777
+ ruleType: plan.capturedFromLiveSession.ruleType ?? plan.ruleType,
1778
+ captureMode: plan.capturedFromLiveSession.captureMode,
1779
+ source: plan.capturedFromLiveSession.source,
1780
+ triggerReload: plan.capturedFromLiveSession.triggerReload,
1781
+ requestHeaders: plan.capturedFromLiveSession.requestHeaders,
1782
+ });
1783
+ }
1784
+ return buildOverrideVariantContext({
1785
+ targetUrl: plan.targetAssetUrl,
1786
+ requestMethod: plan.requestMethod,
1787
+ matchMode: plan.matchMode,
1788
+ ruleType: plan.ruleType,
1789
+ });
1790
+ }
1791
+ function pushOverridePreflightIssue(issues, issue) {
1792
+ const code = typeof issue.code === 'string' ? issue.code : '';
1793
+ const source = typeof issue.source === 'string' ? issue.source : '';
1794
+ const message = typeof issue.message === 'string' ? issue.message : '';
1795
+ if (issues.some((existing) => existing.code === code && existing.source === source && existing.message === message)) {
1796
+ return;
1797
+ }
1798
+ issues.push(issue);
1799
+ }
1800
+ function buildOverridePreflight(options) {
1801
+ const session = options.db
1802
+ .prepare(`
1803
+ SELECT
1804
+ session_id,
1805
+ created_at,
1806
+ last_seen_at,
1807
+ paused_at,
1808
+ ended_at,
1809
+ tab_id,
1810
+ window_id,
1811
+ url_start,
1812
+ url_last,
1813
+ user_agent,
1814
+ viewport_w,
1815
+ viewport_h,
1816
+ dpr,
1817
+ safe_mode,
1818
+ pinned
1819
+ FROM sessions
1820
+ WHERE session_id = ?
1821
+ LIMIT 1
1822
+ `)
1823
+ .get(options.sessionId);
1824
+ const profile = resolveOverrideProfileRecord(options.profileId);
1825
+ const issues = [];
1826
+ const observedAssets = session
1827
+ ? listObservedOverrideAssets(options.db, { sessionId: options.sessionId, limit: 200 })
1828
+ : [];
1829
+ const latestRun = session ? listOverridePocRuns(options.db, options.sessionId, 1, 0).runs[0] ?? null : null;
1830
+ const recentPlans = session
1831
+ ? listOverridePlanAudits(options.db, { sessionId: options.sessionId, limit: 5, offset: 0 }).plans
1832
+ : [];
1833
+ const variantContexts = [...new Map(recentPlans
1834
+ .map((plan) => extractPlanVariantContext(plan))
1835
+ .filter((context) => context !== null)
1836
+ .map((context) => [String(context.variantKey ?? JSON.stringify(context)), context])).values()];
1837
+ const sessionState = options.getSessionConnectionState?.(options.sessionId);
1838
+ const diagnosis = session ? diagnoseOverridePoc(options.db, options.sessionId, latestRun?.runId) : null;
1839
+ for (const issue of buildOverrideProfileIssues(profile)) {
1840
+ pushOverridePreflightIssue(issues, { ...issue, source: 'profile' });
1841
+ }
1842
+ if (!session) {
1843
+ pushOverridePreflightIssue(issues, {
1844
+ code: 'SESSION_NOT_FOUND',
1845
+ severity: 'error',
1846
+ source: 'session',
1847
+ message: `Session not found: ${options.sessionId}`,
1848
+ });
1849
+ }
1850
+ else {
1851
+ const sessionStatus = getSessionStatus(session);
1852
+ if (sessionStatus === 'paused') {
1853
+ pushOverridePreflightIssue(issues, {
1854
+ code: 'SESSION_PAUSED',
1855
+ severity: 'error',
1856
+ source: 'session',
1857
+ message: `Session ${options.sessionId} is paused and cannot enable overrides until it resumes.`,
1858
+ });
1859
+ }
1860
+ if (sessionStatus === 'ended') {
1861
+ pushOverridePreflightIssue(issues, {
1862
+ code: 'SESSION_ENDED',
1863
+ severity: 'error',
1864
+ source: 'session',
1865
+ message: `Session ${options.sessionId} has ended and cannot enable overrides.`,
1866
+ });
1867
+ }
1868
+ if (sessionState && sessionState.connected !== true) {
1869
+ pushOverridePreflightIssue(issues, {
1870
+ code: LIVE_SESSION_DISCONNECTED_CODE,
1871
+ severity: 'error',
1872
+ source: 'connection',
1873
+ message: `Session ${options.sessionId} is not currently connected to the live extension bridge.`,
1874
+ });
1875
+ }
1876
+ }
1877
+ const enabledRules = Array.isArray(profile.rules)
1878
+ ? profile.rules.filter((rule) => isRecord(rule) && rule.enabled === true)
1879
+ : [];
1880
+ const anyServiceWorkerControlled = observedAssets.some((asset) => asset.serviceWorkerControlled);
1881
+ const cspMetaTags = [...new Set(observedAssets.flatMap((asset) => asset.cspMetaTags))];
1882
+ if (observedAssets.length === 0) {
1883
+ pushOverridePreflightIssue(issues, {
1884
+ code: 'NO_OBSERVED_ASSETS',
1885
+ severity: 'warning',
1886
+ source: 'observed-assets',
1887
+ message: 'No observed production assets are stored for this session yet.',
1888
+ });
1889
+ }
1890
+ for (const rule of enabledRules) {
1891
+ const ruleId = String(rule.ruleId ?? 'unknown');
1892
+ const targetAssetUrl = normalizeOptionalString(rule.targetAssetUrl);
1893
+ if (!targetAssetUrl) {
1894
+ continue;
1895
+ }
1896
+ const requestMethod = normalizeOverrideRequestMethod(rule.requestMethod);
1897
+ const matchingAssets = observedAssets.filter((asset) => {
1898
+ return asset.url === targetAssetUrl
1899
+ && normalizeOverrideRequestMethod(asset.requestMethod) === requestMethod;
1900
+ });
1901
+ if (observedAssets.length > 0 && matchingAssets.length === 0) {
1902
+ pushOverridePreflightIssue(issues, {
1903
+ code: 'TARGET_ASSET_NOT_OBSERVED',
1904
+ severity: 'warning',
1905
+ source: 'observed-assets',
1906
+ message: `Rule ${ruleId} target asset was not observed for ${requestMethod} ${targetAssetUrl}.`,
1907
+ });
1908
+ continue;
1909
+ }
1910
+ for (const asset of matchingAssets) {
1911
+ if (typeof asset.integrity === 'string' && asset.integrity.length > 0) {
1912
+ pushOverridePreflightIssue(issues, {
1913
+ code: 'TARGET_ASSET_SRI_PRESENT',
1914
+ severity: 'error',
1915
+ source: 'observed-assets',
1916
+ message: `Rule ${ruleId} target asset ${asset.url} includes integrity="${asset.integrity}" and cannot be overridden safely.`,
1917
+ });
1918
+ }
1919
+ }
1920
+ }
1921
+ if (anyServiceWorkerControlled) {
1922
+ pushOverridePreflightIssue(issues, {
1923
+ code: 'SERVICE_WORKER_CONTROLLED',
1924
+ severity: 'warning',
1925
+ source: 'observed-assets',
1926
+ message: 'The observed page is service-worker controlled; verify the target requests still reach the network path that the debugger can fulfill.',
1927
+ });
1928
+ }
1929
+ if (cspMetaTags.length > 0) {
1930
+ pushOverridePreflightIssue(issues, {
1931
+ code: 'CSP_META_PRESENT',
1932
+ severity: 'warning',
1933
+ source: 'observed-assets',
1934
+ message: `The observed page emitted ${cspMetaTags.length} CSP meta tag(s); document or bootstrap rewrites may still be constrained by page policy.`,
1935
+ });
1936
+ }
1937
+ const ready = !issues.some((issue) => issue.severity === 'error');
1938
+ const nextActions = !ready
1939
+ ? issues.some((issue) => issue.code === 'SERVER_ACTION_UNSUPPORTED')
1940
+ ? [{
1941
+ code: 'REPLAN_SERVER_ACTION_OVERRIDE',
1942
+ message: 'Server actions stay unsupported in production override mode; move the override to a GET document/data/API response.',
1943
+ }]
1944
+ : issues.some((issue) => issue.code === 'MUTATION_REPLAY_UNSUPPORTED')
1945
+ ? [{
1946
+ code: 'REPLAN_MUTATION_OVERRIDE',
1947
+ message: 'Mutation responses are not replay-safe; use a GET document/data/API response path instead.',
1948
+ }]
1949
+ : issues.some((issue) => issue.code === 'UNSAFE_REQUEST_METHOD')
1950
+ ? [{ code: 'REPLAN_GET_ONLY_OVERRIDE', message: 'Remove or regenerate non-GET rules before enabling overrides.' }]
1951
+ : issues.some((issue) => issue.code === 'TARGET_ASSET_SRI_PRESENT')
1952
+ ? [{ code: 'CHOOSE_ANOTHER_OVERRIDE_PATH', message: 'Choose a document/data response path or remove SRI on the production asset before enabling overrides.' }]
1953
+ : issues.some((issue) => issue.code === 'SESSION_NOT_FOUND' || issue.code === 'SESSION_PAUSED' || issue.code === 'SESSION_ENDED' || issue.code === LIVE_SESSION_DISCONNECTED_CODE)
1954
+ ? [{ code: 'RECONNECT_SESSION', message: 'Reconnect or resume the target session before enabling overrides.' }]
1955
+ : buildOverrideProfileNextActions(profile, issues)
1956
+ : observedAssets.length === 0
1957
+ ? [{ code: 'OBSERVE_OVERRIDE_ASSETS', message: 'Run observe_override_assets on the target route before enabling overrides in production workflows.' }]
1958
+ : [{ code: 'ENABLE_OVERRIDES', message: 'Preflight checks passed; the selected profile can be enabled on the live session.' }];
1959
+ return {
1960
+ ready,
1961
+ profileId: profile.profileId,
1962
+ profile,
1963
+ session: session
1964
+ ? {
1965
+ sessionId: session.session_id,
1966
+ status: getSessionStatus(session),
1967
+ lastSeenAt: resolveSessionLastSeenAt(session, sessionState),
1968
+ connected: sessionState?.connected === true,
1969
+ disconnectedAt: sessionState?.disconnectedAt,
1970
+ disconnectReason: sessionState?.disconnectReason,
1971
+ urlLast: session.url_last ?? undefined,
1972
+ tabId: session.tab_id ?? undefined,
1973
+ }
1974
+ : null,
1975
+ issues,
1976
+ checks: {
1977
+ sessionFound: session !== undefined,
1978
+ connected: sessionState?.connected === true,
1979
+ observedAssetCount: observedAssets.length,
1980
+ targetAssetObserved: issues.every((issue) => issue.code !== 'TARGET_ASSET_NOT_OBSERVED'),
1981
+ serviceWorkerControlled: anyServiceWorkerControlled,
1982
+ cspMetaTagCount: cspMetaTags.length,
1983
+ recentPlanCount: recentPlans.length,
1984
+ variantContextCount: variantContexts.length,
1985
+ },
1986
+ observedAssets: {
1987
+ count: observedAssets.length,
1988
+ serviceWorkerControlled: anyServiceWorkerControlled,
1989
+ cspMetaTags,
1990
+ },
1991
+ latestRun,
1992
+ recentPlans,
1993
+ variantContexts,
1994
+ diagnosis,
1995
+ nextActions,
1996
+ };
1997
+ }
1998
+ function normalizeOptionalBooleanInput(value, fieldName) {
1999
+ if (value === undefined) {
2000
+ return undefined;
2001
+ }
2002
+ if (typeof value !== 'boolean') {
2003
+ throw new Error(`${fieldName} must be a boolean when provided`);
2004
+ }
2005
+ return value;
2006
+ }
2007
+ function normalizeOptionalNumberInput(value, fieldName) {
2008
+ if (value === undefined) {
2009
+ return undefined;
2010
+ }
2011
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
2012
+ throw new Error(`${fieldName} must be a finite number when provided`);
2013
+ }
2014
+ return value;
2015
+ }
2016
+ function normalizeOptionalStringArrayInput(value, fieldName) {
2017
+ if (value === undefined) {
2018
+ return undefined;
2019
+ }
2020
+ if (!Array.isArray(value)) {
2021
+ throw new Error(`${fieldName} must be an array of strings when provided`);
2022
+ }
2023
+ return value.map((entry, index) => {
2024
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
2025
+ throw new Error(`${fieldName}[${index}] must be a non-empty string`);
2026
+ }
2027
+ return entry.trim();
2028
+ });
2029
+ }
2030
+ function resolveSessionLastSeenAt(row, state) {
2031
+ return Math.max(row.created_at, row.last_seen_at ?? 0, row.paused_at ?? 0, row.ended_at ?? 0, state?.lastHeartbeatAt ?? 0);
2032
+ }
2033
+ function buildLiveConnectionRecord(row, scope, state) {
2034
+ const status = getSessionStatus(row);
2035
+ const lastSeenAt = resolveSessionLastSeenAt(row, state);
2036
+ const heartbeatAt = state?.lastHeartbeatAt;
2037
+ const heartbeatAgeMs = typeof heartbeatAt === 'number' ? Math.max(0, Date.now() - heartbeatAt) : undefined;
2038
+ const likelyStale = Boolean(!state?.connected
2039
+ && status === 'active'
2040
+ && scope.kind !== 'likely_iframe_noise'
2041
+ && typeof heartbeatAt === 'number'
2042
+ && Date.now() - heartbeatAt <= STALE_LIVE_CONNECTION_GRACE_WINDOW_MS);
2043
+ return {
2044
+ connected: state?.connected === true,
2045
+ connectedAt: state?.connectedAt,
2046
+ lastHeartbeatAt: heartbeatAt,
2047
+ heartbeatAgeMs,
2048
+ disconnectedAt: state?.disconnectedAt,
2049
+ disconnectReason: state?.disconnectReason ?? (status === 'ended' ? 'manual_stop' : undefined),
2050
+ status: status === 'ended'
2051
+ ? 'ended'
2052
+ : status === 'paused'
2053
+ ? 'paused'
2054
+ : state?.connected
2055
+ ? 'connected'
2056
+ : likelyStale
2057
+ ? 'likely_stale'
2058
+ : 'disconnected',
2059
+ captureReady: state?.connected === true && status === 'active',
2060
+ recommendedForLiveCapture: state?.connected === true && status === 'active' && scope.kind !== 'likely_iframe_noise',
2061
+ lastSeenAt,
2062
+ activityAgeMs: Math.max(0, Date.now() - lastSeenAt),
2063
+ };
2064
+ }
2065
+ function buildLiveSessionNextAction(liveConnection, scope) {
2066
+ const liveStatus = typeof liveConnection.status === 'string' ? liveConnection.status : 'disconnected';
2067
+ if (liveStatus === 'connected' && scope.kind !== 'likely_iframe_noise') {
2068
+ return 'Use this session for live capture tools.';
2069
+ }
2070
+ if (liveStatus === 'connected' && scope.kind === 'likely_iframe_noise') {
2071
+ return 'Reconnect on a top-level app tab before relying on live navigation or performance captures.';
2072
+ }
2073
+ if (liveStatus === 'likely_stale') {
2074
+ return 'Retry list_sessions after a fresh app interaction or restart the session if live capture still fails.';
2075
+ }
2076
+ if (liveStatus === 'paused') {
2077
+ return 'Resume the session from the extension popup before using live capture tools.';
2078
+ }
2079
+ if (liveStatus === 'ended') {
2080
+ return 'Start a new extension session before using live capture tools.';
2081
+ }
2082
+ return 'Reconnect or restart the extension session before using live capture tools.';
2083
+ }
2084
+ function buildLiveSessionRecommendedAction(liveConnection, scope) {
2085
+ const liveStatus = typeof liveConnection.status === 'string' ? liveConnection.status : 'disconnected';
2086
+ if (liveStatus === 'connected' && scope.kind !== 'likely_iframe_noise') {
2087
+ return 'ready';
2088
+ }
2089
+ if (liveStatus === 'ended') {
2090
+ return 'start_new_session';
2091
+ }
2092
+ if (liveStatus === 'paused') {
2093
+ return 'resume_session';
2094
+ }
2095
+ return 'reconnect_extension';
1078
2096
  }
1079
2097
  function mapEventRecord(row, profile = 'legacy', options = {}) {
1080
2098
  const payload = readJsonPayload(row.payload_json);
@@ -2155,6 +3173,144 @@ function ensureCaptureSuccess(result, sessionId) {
2155
3173
  }
2156
3174
  return result.payload ?? {};
2157
3175
  }
3176
+ function auditSessionExists(db, sessionId) {
3177
+ const row = db.prepare('SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1').get(sessionId);
3178
+ return row !== undefined;
3179
+ }
3180
+ function hashLocalFileIfPresent(filePath) {
3181
+ if (!filePath || !existsSync(filePath)) {
3182
+ return { sha256: null, bytes: null };
3183
+ }
3184
+ const stat = statSync(filePath);
3185
+ if (!stat.isFile()) {
3186
+ return { sha256: null, bytes: null };
3187
+ }
3188
+ return {
3189
+ sha256: createHash('sha256').update(readFileSync(filePath)).digest('hex'),
3190
+ bytes: stat.size,
3191
+ };
3192
+ }
3193
+ function resolveAuditProfileId(input) {
3194
+ return normalizeOptionalString(input.profileId) ?? null;
3195
+ }
3196
+ function buildOverrideRollbackMetadata(options) {
3197
+ return {
3198
+ disableTool: 'disable_overrides',
3199
+ validateTool: 'validate_override_profile',
3200
+ sessionId: options.sessionId,
3201
+ profileId: options.profileId,
3202
+ configPath: options.configPath ?? null,
3203
+ generatedFiles: Array.from(new Set(options.generatedFiles.filter((entry) => entry.trim().length > 0))),
3204
+ generatedDirectories: Array.from(new Set((options.generatedDirectories ?? []).filter((entry) => entry.trim().length > 0))),
3205
+ notes: [
3206
+ 'Disable overrides for this session before deleting generated files or config entries.',
3207
+ 'Re-run validate_override_profile after editing or removing generated config rules.',
3208
+ options.note,
3209
+ ].filter((entry) => typeof entry === 'string' && entry.length > 0),
3210
+ };
3211
+ }
3212
+ function persistResponsePlanAudit(options) {
3213
+ if (!options.sessionId || !options.plan.rule || !auditSessionExists(options.db, options.sessionId)) {
3214
+ return undefined;
3215
+ }
3216
+ const profileId = resolveAuditProfileId(options.input);
3217
+ const record = {
3218
+ planId: randomUUID(),
3219
+ sessionId: options.sessionId,
3220
+ createdAt: Date.now(),
3221
+ plannerKind: 'response-patch',
3222
+ toolName: 'plan_override_response_patch',
3223
+ profileId,
3224
+ ruleId: options.plan.rule.ruleId,
3225
+ ruleType: options.plan.rule.ruleType,
3226
+ requestMethod: options.plan.requestMethod,
3227
+ matchMode: options.plan.matchMode,
3228
+ targetAssetUrl: options.plan.targetUrl,
3229
+ localFilePath: options.plan.localFilePath ?? options.plan.rule.localFilePath,
3230
+ configPath: options.plan.configPath ?? null,
3231
+ contentType: options.plan.contentType,
3232
+ originalSha256: options.plan.originalSha256,
3233
+ patchedSha256: options.plan.patchedSha256,
3234
+ originalBytes: options.plan.originalBytes,
3235
+ patchedBytes: options.plan.patchedBytes,
3236
+ patchSummary: {
3237
+ textPatches: options.plan.patches,
3238
+ jsonPatches: options.plan.jsonPatches,
3239
+ documentPatches: options.plan.documentPatches,
3240
+ ruleType: options.plan.ruleType,
3241
+ configWritten: options.plan.configWritten,
3242
+ rscFlight: options.plan.rule.rscFlight ?? null,
3243
+ variantContext: options.variantContext ?? null,
3244
+ },
3245
+ preview: options.plan.preview ?? null,
3246
+ warnings: options.plan.warnings,
3247
+ blockers: options.plan.blockers,
3248
+ capturedFromLiveSession: options.capturedFromLiveSession ?? null,
3249
+ rollback: buildOverrideRollbackMetadata({
3250
+ sessionId: options.sessionId,
3251
+ profileId,
3252
+ configPath: options.plan.configPath ?? null,
3253
+ generatedFiles: options.plan.localFilePath ? [options.plan.localFilePath] : [],
3254
+ note: 'Generated response override bodies are disposable once the override has been disabled.',
3255
+ }),
3256
+ };
3257
+ return insertOverridePlanAudit(options.db, record);
3258
+ }
3259
+ function persistNextSourcePlanAudits(options) {
3260
+ if (!options.sessionId || !auditSessionExists(options.db, options.sessionId)) {
3261
+ return [];
3262
+ }
3263
+ const sessionId = options.sessionId;
3264
+ const profileId = resolveAuditProfileId(options.input);
3265
+ const generatedFiles = options.plan.rules.map((rule) => rule.localFilePath);
3266
+ return options.plan.rules.map((rule) => {
3267
+ const localFile = hashLocalFileIfPresent(rule.localFilePath);
3268
+ const record = {
3269
+ planId: randomUUID(),
3270
+ sessionId: options.sessionId,
3271
+ createdAt: Date.now(),
3272
+ plannerKind: 'next-source-overlay',
3273
+ toolName: 'plan_next_source_override',
3274
+ profileId,
3275
+ ruleId: rule.ruleId,
3276
+ ruleType: rule.ruleType,
3277
+ requestMethod: rule.requestMethod,
3278
+ matchMode: rule.matchMode,
3279
+ targetAssetUrl: rule.targetAssetUrl,
3280
+ localFilePath: rule.localFilePath,
3281
+ configPath: options.plan.configPath ?? null,
3282
+ contentType: rule.contentType,
3283
+ originalSha256: null,
3284
+ patchedSha256: localFile.sha256,
3285
+ originalBytes: null,
3286
+ patchedBytes: localFile.bytes,
3287
+ patchSummary: {
3288
+ sourcePaths: options.plan.sourcePaths,
3289
+ editsApplied: options.plan.editsApplied,
3290
+ ruleReason: rule.reason,
3291
+ confidence: rule.confidence,
3292
+ score: rule.score,
3293
+ matchedSourcePaths: rule.matchedSourcePaths,
3294
+ originalAssetPath: rule.originalAssetPath ?? null,
3295
+ build: options.plan.build,
3296
+ configWritten: options.plan.configWritten,
3297
+ },
3298
+ preview: null,
3299
+ warnings: [...options.plan.warnings, ...rule.blockers.map((blocker) => `rule ${rule.ruleId}: ${blocker}`)],
3300
+ blockers: options.plan.blockers,
3301
+ capturedFromLiveSession: null,
3302
+ rollback: buildOverrideRollbackMetadata({
3303
+ sessionId,
3304
+ profileId,
3305
+ configPath: options.plan.configPath ?? null,
3306
+ generatedFiles,
3307
+ generatedDirectories: [options.plan.overlayRoot],
3308
+ note: 'Generated Next.js overlay folders are disposable once the override has been disabled.',
3309
+ }),
3310
+ };
3311
+ return insertOverridePlanAudit(options.db, record);
3312
+ });
3313
+ }
2158
3314
  function normalizeSnapshotResponsePayload(payload, options) {
2159
3315
  const snapshotRecord = structuredClone(payload);
2160
3316
  const snapshotRoot = snapshotRecord.snapshot;
@@ -2265,7 +3421,12 @@ export function createV1ToolHandlers(getDb, getSessionConnectionState) {
2265
3421
  const where = [];
2266
3422
  const params = [];
2267
3423
  if (sinceMinutes !== undefined && Number.isFinite(sinceMinutes) && sinceMinutes > 0) {
2268
- where.push('created_at >= ?');
3424
+ where.push(`
3425
+ CASE
3426
+ WHEN COALESCE(last_seen_at, 0) > created_at THEN COALESCE(last_seen_at, 0)
3427
+ ELSE created_at
3428
+ END >= ?
3429
+ `);
2269
3430
  params.push(Date.now() - Math.floor(sinceMinutes * 60_000));
2270
3431
  }
2271
3432
  const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
@@ -2273,6 +3434,7 @@ export function createV1ToolHandlers(getDb, getSessionConnectionState) {
2273
3434
  SELECT
2274
3435
  session_id,
2275
3436
  created_at,
3437
+ last_seen_at,
2276
3438
  paused_at,
2277
3439
  ended_at,
2278
3440
  tab_id,
@@ -2287,204 +3449,596 @@ export function createV1ToolHandlers(getDb, getSessionConnectionState) {
2287
3449
  pinned
2288
3450
  FROM sessions
2289
3451
  ${whereClause}
2290
- ORDER BY created_at DESC
3452
+ ORDER BY
3453
+ CASE
3454
+ WHEN COALESCE(last_seen_at, 0) > created_at THEN COALESCE(last_seen_at, 0)
3455
+ ELSE created_at
3456
+ END DESC,
3457
+ created_at DESC
2291
3458
  LIMIT ? OFFSET ?
2292
3459
  `;
2293
3460
  const rows = db.prepare(sql).all(...params, limit + 1, offset);
2294
3461
  const truncatedByLimit = rows.length > limit;
2295
- const sessions = rows.slice(0, limit).map((row) => ({
2296
- sessionId: row.session_id,
2297
- createdAt: row.created_at,
2298
- pausedAt: row.paused_at ?? undefined,
2299
- endedAt: row.ended_at ?? undefined,
2300
- status: row.ended_at ? 'ended' : row.paused_at ? 'paused' : 'active',
2301
- tabId: row.tab_id ?? undefined,
2302
- windowId: row.window_id ?? undefined,
2303
- urlStart: row.url_start ?? undefined,
2304
- urlLast: row.url_last ?? undefined,
2305
- userAgent: row.user_agent ?? undefined,
2306
- viewport: row.viewport_w !== null && row.viewport_h !== null
3462
+ const sessions = rows.slice(0, limit).map((row) => {
3463
+ const status = getSessionStatus(row);
3464
+ const state = getSessionConnectionState?.(row.session_id);
3465
+ const lastUrl = row.url_last ?? undefined;
3466
+ const scope = classifySessionUrl(lastUrl);
3467
+ const liveConnection = buildLiveConnectionRecord(row, scope, state);
3468
+ return {
3469
+ sessionId: row.session_id,
3470
+ createdAt: row.created_at,
3471
+ lastSeenAt: resolveSessionLastSeenAt(row, state),
3472
+ pausedAt: row.paused_at ?? undefined,
3473
+ endedAt: row.ended_at ?? undefined,
3474
+ status,
3475
+ tabId: row.tab_id ?? undefined,
3476
+ windowId: row.window_id ?? undefined,
3477
+ urlStart: row.url_start ?? undefined,
3478
+ urlLast: lastUrl,
3479
+ lastUrl,
3480
+ userAgent: row.user_agent ?? undefined,
3481
+ viewport: row.viewport_w !== null && row.viewport_h !== null
3482
+ ? {
3483
+ width: row.viewport_w,
3484
+ height: row.viewport_h,
3485
+ }
3486
+ : undefined,
3487
+ dpr: row.dpr ?? undefined,
3488
+ safeMode: row.safe_mode === 1,
3489
+ pinned: row.pinned === 1,
3490
+ scope,
3491
+ liveConnection,
3492
+ };
3493
+ });
3494
+ const bytePage = applyByteBudget(sessions, maxResponseBytes);
3495
+ const truncated = truncatedByLimit || bytePage.truncatedByBytes;
3496
+ return {
3497
+ ...createBaseResponse(),
3498
+ limitsApplied: {
3499
+ maxResults: limit,
3500
+ truncated,
3501
+ },
3502
+ pagination: buildOffsetPagination(offset, bytePage.items.length, truncated, maxResponseBytes),
3503
+ responseBytes: bytePage.responseBytes,
3504
+ sessions: bytePage.items,
3505
+ };
3506
+ },
3507
+ get_session_summary: async (input) => {
3508
+ const db = getDb();
3509
+ const sessionId = getSessionId(input);
3510
+ if (!sessionId) {
3511
+ throw new Error('sessionId is required');
3512
+ }
3513
+ const session = db
3514
+ .prepare('SELECT session_id, created_at, ended_at, url_last, pinned FROM sessions WHERE session_id = ?')
3515
+ .get(sessionId);
3516
+ if (!session) {
3517
+ throw new Error(`Session not found: ${sessionId}`);
3518
+ }
3519
+ const counters = db
3520
+ .prepare(`
3521
+ SELECT
3522
+ SUM(CASE WHEN type = 'error' THEN 1 ELSE 0 END) AS errors,
3523
+ SUM(CASE WHEN type = 'console' AND json_extract(payload_json, '$.level') = 'warn' THEN 1 ELSE 0 END) AS warnings
3524
+ FROM events
3525
+ WHERE session_id = ?
3526
+ `)
3527
+ .get(sessionId);
3528
+ const networkFails = db
3529
+ .prepare(`
3530
+ SELECT COUNT(*) AS count
3531
+ FROM network
3532
+ WHERE session_id = ?
3533
+ AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
3534
+ `)
3535
+ .get(sessionId);
3536
+ const latestNav = db
3537
+ .prepare(`
3538
+ SELECT payload_json
3539
+ FROM events
3540
+ WHERE session_id = ? AND type = 'nav'
3541
+ ORDER BY ts DESC
3542
+ LIMIT 1
3543
+ `)
3544
+ .get(sessionId);
3545
+ const eventRange = db
3546
+ .prepare(`
3547
+ SELECT MIN(ts) AS start_ts, MAX(ts) AS end_ts
3548
+ FROM events
3549
+ WHERE session_id = ?
3550
+ `)
3551
+ .get(sessionId);
3552
+ const navPayload = latestNav ? readJsonPayload(latestNav.payload_json) : {};
3553
+ const lastUrl = resolveLastUrl(navPayload) ?? session.url_last ?? undefined;
3554
+ return {
3555
+ ...createBaseResponse(sessionId),
3556
+ counts: {
3557
+ errors: counters.errors ?? 0,
3558
+ warnings: counters.warnings ?? 0,
3559
+ networkFails: networkFails.count,
3560
+ },
3561
+ lastUrl,
3562
+ timeRange: {
3563
+ start: eventRange.start_ts ?? session.created_at,
3564
+ end: eventRange.end_ts ?? session.ended_at ?? session.created_at,
3565
+ },
3566
+ pinned: session.pinned === 1,
3567
+ };
3568
+ },
3569
+ get_live_session_health: async (input) => {
3570
+ const db = getDb();
3571
+ const sessionId = getSessionId(input);
3572
+ if (!sessionId) {
3573
+ throw new Error('sessionId is required');
3574
+ }
3575
+ const session = db
3576
+ .prepare(`
3577
+ SELECT
3578
+ session_id,
3579
+ created_at,
3580
+ last_seen_at,
3581
+ paused_at,
3582
+ ended_at,
3583
+ tab_id,
3584
+ window_id,
3585
+ url_start,
3586
+ url_last,
3587
+ viewport_w,
3588
+ viewport_h,
3589
+ dpr,
3590
+ safe_mode,
3591
+ pinned
3592
+ FROM sessions
3593
+ WHERE session_id = ?
3594
+ `)
3595
+ .get(sessionId);
3596
+ if (!session) {
3597
+ throw new Error(`Session not found: ${sessionId}`);
3598
+ }
3599
+ const latestNav = db
3600
+ .prepare(`
3601
+ SELECT payload_json
3602
+ FROM events
3603
+ WHERE session_id = ? AND type = 'nav'
3604
+ ORDER BY ts DESC
3605
+ LIMIT 1
3606
+ `)
3607
+ .get(sessionId);
3608
+ const navPayload = latestNav ? readJsonPayload(latestNav.payload_json) : {};
3609
+ const lastUrl = resolveLastUrl(navPayload) ?? session.url_last ?? undefined;
3610
+ const scope = classifySessionUrl(lastUrl);
3611
+ const connectionState = getSessionConnectionState?.(sessionId);
3612
+ const liveConnection = buildLiveConnectionRecord(session, scope, connectionState);
3613
+ const status = getSessionStatus(session);
3614
+ const lastSeenAt = resolveSessionLastSeenAt(session, connectionState);
3615
+ const nextAction = buildLiveSessionNextAction(liveConnection, scope);
3616
+ const recommendedAction = buildLiveSessionRecommendedAction(liveConnection, scope);
3617
+ const sessionRecord = {
3618
+ sessionId: session.session_id,
3619
+ createdAt: session.created_at,
3620
+ lastSeenAt,
3621
+ pausedAt: session.paused_at ?? undefined,
3622
+ endedAt: session.ended_at ?? undefined,
3623
+ status,
3624
+ tabId: session.tab_id ?? undefined,
3625
+ windowId: session.window_id ?? undefined,
3626
+ urlStart: session.url_start ?? undefined,
3627
+ urlLast: session.url_last ?? undefined,
3628
+ lastUrl,
3629
+ viewport: session.viewport_w !== null && session.viewport_h !== null
2307
3630
  ? {
2308
- width: row.viewport_w,
2309
- height: row.viewport_h,
3631
+ width: session.viewport_w,
3632
+ height: session.viewport_h,
2310
3633
  }
2311
3634
  : undefined,
2312
- dpr: row.dpr ?? undefined,
2313
- safeMode: row.safe_mode === 1,
2314
- pinned: row.pinned === 1,
2315
- liveConnection: (() => {
2316
- const state = getSessionConnectionState?.(row.session_id);
2317
- if (!state) {
2318
- return {
2319
- connected: false,
2320
- lastHeartbeatAt: undefined,
2321
- disconnectReason: row.ended_at ? 'manual_stop' : undefined,
2322
- };
2323
- }
2324
- return {
2325
- connected: state.connected,
2326
- connectedAt: state.connectedAt,
2327
- lastHeartbeatAt: state.lastHeartbeatAt,
2328
- disconnectedAt: state.disconnectedAt,
2329
- disconnectReason: state.disconnectReason,
2330
- };
2331
- })(),
2332
- }));
2333
- const bytePage = applyByteBudget(sessions, maxResponseBytes);
2334
- const truncated = truncatedByLimit || bytePage.truncatedByBytes;
3635
+ dpr: session.dpr ?? undefined,
3636
+ safeMode: session.safe_mode === 1,
3637
+ pinned: session.pinned === 1,
3638
+ };
3639
+ return {
3640
+ ...createBaseResponse(sessionId),
3641
+ status,
3642
+ createdAt: session.created_at,
3643
+ lastSeenAt,
3644
+ pausedAt: session.paused_at ?? undefined,
3645
+ endedAt: session.ended_at ?? undefined,
3646
+ tabId: session.tab_id ?? undefined,
3647
+ windowId: session.window_id ?? undefined,
3648
+ lastUrl,
3649
+ safeMode: session.safe_mode === 1,
3650
+ pinned: session.pinned === 1,
3651
+ session: sessionRecord,
3652
+ scope,
3653
+ liveConnection,
3654
+ nextAction,
3655
+ recommendedAction,
3656
+ };
3657
+ },
3658
+ list_override_profiles: async () => {
3659
+ const profiles = buildOverrideProfileRecords();
3660
+ return {
3661
+ ...createBaseResponse(),
3662
+ limitsApplied: {
3663
+ maxResults: profiles.length,
3664
+ truncated: false,
3665
+ },
3666
+ profiles,
3667
+ nextActions: profiles.length > 0
3668
+ ? [{ code: 'VALIDATE_PROFILE', message: 'Run validate_override_profile before enabling overrides.' }]
3669
+ : [{ code: 'CREATE_PROFILE', message: 'Run create_override_profile to generate a candidate profile.' }],
3670
+ };
3671
+ },
3672
+ create_override_profile: async (input) => {
3673
+ const adapterInput = normalizeOptionalString(input.adapter) ?? normalizeOptionalString(input.mode);
3674
+ let adapter;
3675
+ if (adapterInput !== undefined) {
3676
+ if (!OVERRIDE_PROFILE_ADAPTERS.includes(adapterInput)) {
3677
+ throw new Error(`adapter must be one of: ${OVERRIDE_PROFILE_ADAPTERS.join(', ')}`);
3678
+ }
3679
+ adapter = adapterInput;
3680
+ }
3681
+ const targetBaseUrl = normalizeOptionalString(input.targetBaseUrl);
3682
+ if (!targetBaseUrl) {
3683
+ throw new Error('targetBaseUrl is required, for example https://example.com/_next/ or https://example.com/assets/');
3684
+ }
3685
+ const generated = createOverrideProfileConfig({
3686
+ adapter,
3687
+ targetBaseUrl,
3688
+ projectRoot: normalizeOptionalString(input.projectRoot),
3689
+ assetRoot: normalizeOptionalString(input.assetRoot),
3690
+ nextDir: normalizeOptionalString(input.nextDir),
3691
+ configPath: normalizeOptionalString(input.configPath),
3692
+ profileId: normalizeOptionalString(input.profileId),
3693
+ profileName: normalizeOptionalString(input.profileName),
3694
+ enabled: normalizeOptionalBooleanInput(input.enabled, 'enabled'),
3695
+ profileEnabled: normalizeOptionalBooleanInput(input.profileEnabled, 'profileEnabled'),
3696
+ autoReload: normalizeOptionalBooleanInput(input.autoReload, 'autoReload'),
3697
+ includeManifestFiles: normalizeOptionalBooleanInput(input.includeManifestFiles, 'includeManifestFiles'),
3698
+ includeStaticFiles: normalizeOptionalBooleanInput(input.includeStaticFiles, 'includeStaticFiles'),
3699
+ extensions: normalizeOptionalStringArrayInput(input.extensions, 'extensions'),
3700
+ maxRules: normalizeOptionalNumberInput(input.maxRules, 'maxRules'),
3701
+ });
3702
+ const writeConfig = normalizeOptionalBooleanInput(input.writeConfig, 'writeConfig') ?? false;
3703
+ const overwrite = normalizeOptionalBooleanInput(input.overwrite, 'overwrite') ?? false;
3704
+ const write = {
3705
+ written: false,
3706
+ path: generated.suggestedConfigPath,
3707
+ };
3708
+ let nextActions = generated.nextActions;
3709
+ if (writeConfig && generated.ruleCount === 0) {
3710
+ write.failureCode = 'NO_RULES';
3711
+ write.message = 'Generated profile has no rules; config was not written.';
3712
+ nextActions = [{
3713
+ code: 'BUILD_APP',
3714
+ message: 'Build the app so local assets exist, then generate the profile again.',
3715
+ }];
3716
+ }
3717
+ else if (writeConfig && existsSync(generated.suggestedConfigPath) && !overwrite) {
3718
+ write.failureCode = 'CONFIG_EXISTS';
3719
+ write.message = 'Config file already exists; pass overwrite=true or choose another configPath.';
3720
+ nextActions = [{
3721
+ code: 'OVERWRITE_OR_CHOOSE_CONFIG_PATH',
3722
+ message: 'Pass overwrite=true to replace the config file, or choose a different configPath.',
3723
+ }, ...generated.nextActions];
3724
+ }
3725
+ else if (writeConfig) {
3726
+ mkdirSync(dirname(generated.suggestedConfigPath), { recursive: true });
3727
+ writeFileSync(generated.suggestedConfigPath, generated.configJson, 'utf8');
3728
+ write.written = true;
3729
+ write.bytes = Buffer.byteLength(generated.configJson, 'utf8');
3730
+ nextActions = generated.nextActions.filter((action) => action.code !== 'SAVE_LOCAL_CONFIG');
3731
+ }
3732
+ return {
3733
+ ...createBaseResponse(),
3734
+ limitsApplied: {
3735
+ maxResults: generated.ruleCount,
3736
+ truncated: generated.warnings.some((warning) => warning.startsWith('Rule generation was limited')),
3737
+ },
3738
+ adapter: generated.adapter,
3739
+ mode: generated.mode,
3740
+ projectRoot: generated.projectRoot,
3741
+ assetRoot: generated.assetRoot,
3742
+ nextDir: generated.nextDir,
3743
+ targetBaseUrl: generated.targetBaseUrl,
3744
+ suggestedConfigPath: generated.suggestedConfigPath,
3745
+ ruleCount: generated.ruleCount,
3746
+ manifestFiles: generated.manifestFiles,
3747
+ staticFileCount: generated.staticFileCount,
3748
+ missingManifestAssetCount: generated.missingManifestAssetCount,
3749
+ warnings: generated.warnings,
3750
+ nextActions,
3751
+ write,
3752
+ profile: generated.profile,
3753
+ config: generated.config,
3754
+ configJson: generated.configJson,
3755
+ };
3756
+ },
3757
+ validate_override_profile: async (input) => {
3758
+ const profile = resolveOverrideProfileRecord(input.profileId);
3759
+ const issues = buildOverrideProfileIssues(profile);
3760
+ return {
3761
+ ...createBaseResponse(),
3762
+ profileId: profile.profileId,
3763
+ valid: !issues.some((issue) => issue.severity === 'error'),
3764
+ issues,
3765
+ nextActions: buildOverrideProfileNextActions(profile, issues),
3766
+ profile,
3767
+ };
3768
+ },
3769
+ preflight_overrides: async (input) => {
3770
+ const db = getDb();
3771
+ const sessionId = getSessionId(input);
3772
+ if (!sessionId) {
3773
+ throw new Error('sessionId is required');
3774
+ }
3775
+ const preflight = buildOverridePreflight({
3776
+ db,
3777
+ sessionId,
3778
+ profileId: input.profileId,
3779
+ getSessionConnectionState,
3780
+ });
3781
+ return {
3782
+ ...createBaseResponse(sessionId),
3783
+ ...preflight,
3784
+ };
3785
+ },
3786
+ list_observed_override_assets: async (input) => {
3787
+ const sessionId = getSessionId(input);
3788
+ if (!sessionId) {
3789
+ throw new Error('sessionId is required');
3790
+ }
3791
+ const assets = listObservedOverrideAssets(getDb(), {
3792
+ sessionId,
3793
+ limit: typeof input.limit === 'number' ? input.limit : undefined,
3794
+ sinceTimestamp: typeof input.sinceTimestamp === 'number' ? input.sinceTimestamp : undefined,
3795
+ });
3796
+ return {
3797
+ ...createBaseResponse(sessionId),
3798
+ limitsApplied: {
3799
+ maxResults: assets.length,
3800
+ truncated: false,
3801
+ },
3802
+ assets,
3803
+ };
3804
+ },
3805
+ plan_override_response_patch: async (input) => {
3806
+ const sessionId = getSessionId(input);
3807
+ const plan = planOverrideResponsePatch(input);
3808
+ const variantContext = buildOverrideVariantContext({
3809
+ targetUrl: plan.targetUrl,
3810
+ requestMethod: plan.requestMethod,
3811
+ matchMode: plan.matchMode,
3812
+ ruleType: plan.ruleType,
3813
+ captureMode: input.captureMode,
3814
+ source: input.source,
3815
+ triggerReload: input.triggerReload,
3816
+ requestHeaders: input.requestHeaders,
3817
+ });
3818
+ const auditPlan = persistResponsePlanAudit({
3819
+ db: getDb(),
3820
+ sessionId,
3821
+ input,
3822
+ plan,
3823
+ variantContext,
3824
+ });
3825
+ return {
3826
+ ...createBaseResponse(sessionId),
3827
+ limitsApplied: {
3828
+ maxResults: plan.rule ? 1 : 0,
3829
+ truncated: false,
3830
+ },
3831
+ variantContext,
3832
+ audit: {
3833
+ persisted: auditPlan !== undefined,
3834
+ plans: auditPlan ? [auditPlan] : [],
3835
+ },
3836
+ ...plan,
3837
+ };
3838
+ },
3839
+ map_next_override_assets: async (input) => {
3840
+ const projectRoot = normalizeOptionalString(input.projectRoot);
3841
+ if (!projectRoot) {
3842
+ throw new Error('projectRoot is required');
3843
+ }
3844
+ const sessionId = getSessionId(input);
3845
+ const observedAssets = Array.isArray(input.observedAssets)
3846
+ ? input.observedAssets
3847
+ : sessionId
3848
+ ? listObservedOverrideAssets(getDb(), { sessionId })
3849
+ : input.observedAssets;
3850
+ const mapping = await mapNextOverrideAssetsWithDrift({
3851
+ projectRoot,
3852
+ nextDir: normalizeOptionalString(input.nextDir),
3853
+ observedAssets,
3854
+ sourcePaths: input.sourcePaths,
3855
+ route: input.route,
3856
+ maxResults: input.maxResults,
3857
+ fetchProductionAssets: input.fetchProductionAssets,
3858
+ productionFetchTimeoutMs: input.productionFetchTimeoutMs,
3859
+ maxProductionAssetBytes: input.maxProductionAssetBytes,
3860
+ maxDriftCandidates: input.maxDriftCandidates,
3861
+ productionFetchConcurrency: input.productionFetchConcurrency,
3862
+ });
3863
+ return {
3864
+ ...createBaseResponse(sessionId),
3865
+ limitsApplied: {
3866
+ maxResults: mapping.candidates.length,
3867
+ truncated: false,
3868
+ },
3869
+ observedFromPersisted: !Array.isArray(input.observedAssets) && sessionId
3870
+ ? { sessionId, assetCount: Array.isArray(observedAssets) ? observedAssets.length : 0 }
3871
+ : undefined,
3872
+ ...mapping,
3873
+ };
3874
+ },
3875
+ plan_next_source_override: async (input) => {
3876
+ const projectRoot = normalizeOptionalString(input.projectRoot);
3877
+ if (!projectRoot) {
3878
+ throw new Error('projectRoot is required');
3879
+ }
3880
+ const sessionId = getSessionId(input);
3881
+ const observedAssets = Array.isArray(input.observedAssets)
3882
+ ? input.observedAssets
3883
+ : sessionId
3884
+ ? listObservedOverrideAssets(getDb(), { sessionId })
3885
+ : input.observedAssets;
3886
+ const plan = await planNextSourceOverride({
3887
+ projectRoot,
3888
+ nextDir: normalizeOptionalString(input.nextDir),
3889
+ observedAssets,
3890
+ sourceEdits: input.sourceEdits,
3891
+ sourcePaths: input.sourcePaths,
3892
+ route: input.route,
3893
+ configPath: input.configPath,
3894
+ writeConfig: input.writeConfig,
3895
+ overwrite: input.overwrite,
3896
+ enabled: input.enabled,
3897
+ profileEnabled: input.profileEnabled,
3898
+ autoReload: input.autoReload,
3899
+ profileId: input.profileId,
3900
+ profileName: input.profileName,
3901
+ buildTimeoutMs: input.buildTimeoutMs,
3902
+ maxRules: input.maxRules,
3903
+ fetchProductionAssets: input.fetchProductionAssets,
3904
+ productionFetchTimeoutMs: input.productionFetchTimeoutMs,
3905
+ maxProductionAssetBytes: input.maxProductionAssetBytes,
3906
+ maxDriftCandidates: input.maxDriftCandidates,
3907
+ productionFetchConcurrency: input.productionFetchConcurrency,
3908
+ overlayTtlMs: input.overlayTtlMs,
3909
+ });
3910
+ const auditPlans = persistNextSourcePlanAudits({
3911
+ db: getDb(),
3912
+ sessionId,
3913
+ input,
3914
+ plan,
3915
+ });
3916
+ return {
3917
+ ...createBaseResponse(sessionId),
3918
+ limitsApplied: {
3919
+ maxResults: plan.rules.length,
3920
+ truncated: false,
3921
+ },
3922
+ observedFromPersisted: !Array.isArray(input.observedAssets) && sessionId
3923
+ ? { sessionId, assetCount: Array.isArray(observedAssets) ? observedAssets.length : 0 }
3924
+ : undefined,
3925
+ audit: {
3926
+ persisted: auditPlans.length > 0,
3927
+ plans: auditPlans,
3928
+ },
3929
+ ...plan,
3930
+ };
3931
+ },
3932
+ get_override_status: async (input) => {
3933
+ const db = getDb();
3934
+ const sessionId = getSessionId(input);
3935
+ const profile = resolveOverrideProfileRecord(input.profileId);
3936
+ const latestRun = sessionId ? listOverridePocRuns(db, sessionId, 1, 0).runs[0] ?? null : null;
3937
+ const recentRequests = sessionId
3938
+ ? listOverridePocRequests(db, sessionId, 5, 0, latestRun?.runId).requests
3939
+ : [];
3940
+ const recentPlans = sessionId
3941
+ ? listOverridePlanAudits(db, { sessionId, limit: 5, offset: 0 }).plans
3942
+ : [];
3943
+ return {
3944
+ ...createBaseResponse(sessionId),
3945
+ profile,
3946
+ latestRun,
3947
+ recentRequests,
3948
+ recentPlans,
3949
+ preflight: sessionId
3950
+ ? buildOverridePreflight({
3951
+ db,
3952
+ sessionId,
3953
+ profileId: input.profileId,
3954
+ getSessionConnectionState,
3955
+ })
3956
+ : null,
3957
+ diagnosis: sessionId ? diagnoseOverridePoc(db, sessionId, latestRun?.runId) : null,
3958
+ nextActions: latestRun?.lastErrorCode
3959
+ ? [{ code: 'DIAGNOSE_OVERRIDES', message: 'Run diagnose_overrides for the latest failed override run.' }]
3960
+ : latestRun
3961
+ ? [{ code: 'GET_OVERRIDE_REQUEST_LOG', message: 'Inspect get_override_request_log for matched and fulfilled requests.' }]
3962
+ : [{ code: 'ENABLE_OVERRIDES', message: 'Enable overrides on a connected session after profile validation succeeds.' }],
3963
+ };
3964
+ },
3965
+ get_override_request_log: async (input) => {
3966
+ const db = getDb();
3967
+ const sessionId = getSessionId(input);
3968
+ if (!sessionId) {
3969
+ throw new Error('sessionId is required');
3970
+ }
3971
+ const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
3972
+ const offset = resolveOffset(input.offset);
3973
+ const maxResponseBytes = resolveMaxResponseBytes(input.maxResponseBytes);
3974
+ const runId = typeof input.runId === 'string' && input.runId.trim().length > 0
3975
+ ? input.runId.trim()
3976
+ : undefined;
3977
+ const result = listOverridePocRequests(db, sessionId, limit, offset, runId);
3978
+ const bytePage = applyByteBudget(result.requests, maxResponseBytes);
3979
+ const truncated = result.hasMore || bytePage.truncatedByBytes;
2335
3980
  return {
2336
- ...createBaseResponse(),
3981
+ ...createBaseResponse(sessionId),
2337
3982
  limitsApplied: {
2338
3983
  maxResults: limit,
2339
3984
  truncated,
2340
3985
  },
3986
+ runId: runId ?? null,
2341
3987
  pagination: buildOffsetPagination(offset, bytePage.items.length, truncated, maxResponseBytes),
2342
3988
  responseBytes: bytePage.responseBytes,
2343
- sessions: bytePage.items,
3989
+ requests: bytePage.items,
3990
+ nextActions: bytePage.items.length === 0
3991
+ ? [{ code: 'RELOAD_TAB', message: 'Reload the selected tab after enabling overrides so matching requests are observed.' }]
3992
+ : [{ code: 'DIAGNOSE_OVERRIDES', message: 'Run diagnose_overrides if any matched request failed or did not fulfill.' }],
2344
3993
  };
2345
3994
  },
2346
- get_live_session_health: async (input) => {
3995
+ get_override_plan_log: async (input) => {
2347
3996
  const db = getDb();
2348
3997
  const sessionId = getSessionId(input);
2349
3998
  if (!sessionId) {
2350
3999
  throw new Error('sessionId is required');
2351
4000
  }
2352
- const session = db
2353
- .prepare(`
2354
- SELECT
2355
- session_id,
2356
- created_at,
2357
- paused_at,
2358
- ended_at,
2359
- tab_id,
2360
- window_id,
2361
- url_start,
2362
- url_last,
2363
- viewport_w,
2364
- viewport_h,
2365
- dpr,
2366
- safe_mode,
2367
- pinned
2368
- FROM sessions
2369
- WHERE session_id = ?
2370
- LIMIT 1
2371
- `)
2372
- .get(sessionId);
2373
- if (!session) {
2374
- throw new Error(`Session not found: ${sessionId}`);
2375
- }
2376
- const connection = getSessionConnectionState?.(sessionId);
2377
- const now = Date.now();
2378
- const lastSeenAt = connection?.connected
2379
- ? connection.lastHeartbeatAt
2380
- : connection?.disconnectedAt ?? session.ended_at ?? session.paused_at ?? session.created_at;
2381
- const staleForMs = lastSeenAt ? Math.max(0, now - lastSeenAt) : undefined;
4001
+ const limit = resolveLimit(input.limit, DEFAULT_EVENT_LIMIT);
4002
+ const offset = resolveOffset(input.offset);
4003
+ const maxResponseBytes = resolveMaxResponseBytes(input.maxResponseBytes);
4004
+ const planId = typeof input.planId === 'string' && input.planId.trim().length > 0
4005
+ ? input.planId.trim()
4006
+ : undefined;
4007
+ const result = listOverridePlanAudits(db, { sessionId, limit, offset, planId });
4008
+ const bytePage = applyByteBudget(result.plans, maxResponseBytes);
4009
+ const truncated = result.hasMore || bytePage.truncatedByBytes;
2382
4010
  return {
2383
4011
  ...createBaseResponse(sessionId),
2384
4012
  limitsApplied: {
2385
- maxResults: 1,
2386
- truncated: false,
2387
- },
2388
- session: {
2389
- sessionId: session.session_id,
2390
- createdAt: session.created_at,
2391
- pausedAt: session.paused_at ?? undefined,
2392
- endedAt: session.ended_at ?? undefined,
2393
- status: session.ended_at ? 'ended' : session.paused_at ? 'paused' : 'active',
2394
- tabId: session.tab_id ?? undefined,
2395
- windowId: session.window_id ?? undefined,
2396
- urlStart: session.url_start ?? undefined,
2397
- urlLast: session.url_last ?? undefined,
2398
- viewport: session.viewport_w !== null && session.viewport_h !== null
2399
- ? {
2400
- width: session.viewport_w,
2401
- height: session.viewport_h,
2402
- }
2403
- : undefined,
2404
- dpr: session.dpr ?? undefined,
2405
- safeMode: session.safe_mode === 1,
2406
- pinned: session.pinned === 1,
4013
+ maxResults: limit,
4014
+ truncated,
2407
4015
  },
2408
- liveConnection: connection
2409
- ? {
2410
- connected: connection.connected,
2411
- connectedAt: connection.connectedAt,
2412
- lastHeartbeatAt: connection.lastHeartbeatAt,
2413
- disconnectedAt: connection.disconnectedAt,
2414
- disconnectReason: connection.disconnectReason,
2415
- staleForMs,
2416
- }
2417
- : {
2418
- connected: false,
2419
- staleForMs,
2420
- },
2421
- recommendedAction: connection?.connected
2422
- ? 'ready'
2423
- : session.ended_at
2424
- ? 'start_new_session'
2425
- : 'reconnect_extension',
4016
+ planId: planId ?? null,
4017
+ pagination: buildOffsetPagination(offset, bytePage.items.length, truncated, maxResponseBytes),
4018
+ responseBytes: bytePage.responseBytes,
4019
+ plans: bytePage.items,
4020
+ nextActions: bytePage.items.length === 0
4021
+ ? [{ code: 'PLAN_OVERRIDE', message: 'Run plan_override_response_patch or plan_next_source_override with sessionId to persist generated rule metadata.' }]
4022
+ : [{ code: 'REVIEW_ROLLBACK', message: 'Review rollback metadata before enabling or deleting generated override files.' }],
2426
4023
  };
2427
4024
  },
2428
- get_session_summary: async (input) => {
4025
+ diagnose_overrides: async (input) => {
2429
4026
  const db = getDb();
2430
4027
  const sessionId = getSessionId(input);
2431
4028
  if (!sessionId) {
2432
4029
  throw new Error('sessionId is required');
2433
4030
  }
2434
- const session = db
2435
- .prepare('SELECT session_id, created_at, ended_at, url_last, pinned FROM sessions WHERE session_id = ?')
2436
- .get(sessionId);
2437
- if (!session) {
2438
- throw new Error(`Session not found: ${sessionId}`);
2439
- }
2440
- const counters = db
2441
- .prepare(`
2442
- SELECT
2443
- SUM(CASE WHEN type = 'error' THEN 1 ELSE 0 END) AS errors,
2444
- SUM(CASE WHEN type = 'console' AND json_extract(payload_json, '$.level') = 'warn' THEN 1 ELSE 0 END) AS warnings
2445
- FROM events
2446
- WHERE session_id = ?
2447
- `)
2448
- .get(sessionId);
2449
- const networkFails = db
2450
- .prepare(`
2451
- SELECT COUNT(*) AS count
2452
- FROM network
2453
- WHERE session_id = ?
2454
- AND (error_class IS NOT NULL OR COALESCE(status, 0) >= 400)
2455
- `)
2456
- .get(sessionId);
2457
- const latestNav = db
2458
- .prepare(`
2459
- SELECT payload_json
2460
- FROM events
2461
- WHERE session_id = ? AND type = 'nav'
2462
- ORDER BY ts DESC
2463
- LIMIT 1
2464
- `)
2465
- .get(sessionId);
2466
- const eventRange = db
2467
- .prepare(`
2468
- SELECT MIN(ts) AS start_ts, MAX(ts) AS end_ts
2469
- FROM events
2470
- WHERE session_id = ?
2471
- `)
2472
- .get(sessionId);
2473
- const navPayload = latestNav ? readJsonPayload(latestNav.payload_json) : {};
2474
- const lastUrl = resolveLastUrl(navPayload) ?? session.url_last ?? undefined;
4031
+ const runId = typeof input.runId === 'string' && input.runId.trim().length > 0
4032
+ ? input.runId.trim()
4033
+ : undefined;
4034
+ const diagnosis = diagnoseOverridePoc(db, sessionId, runId);
4035
+ const firstIssue = diagnosis.issues[0];
2475
4036
  return {
2476
4037
  ...createBaseResponse(sessionId),
2477
- counts: {
2478
- errors: counters.errors ?? 0,
2479
- warnings: counters.warnings ?? 0,
2480
- networkFails: networkFails.count,
2481
- },
2482
- lastUrl,
2483
- timeRange: {
2484
- start: eventRange.start_ts ?? session.created_at,
2485
- end: eventRange.end_ts ?? session.ended_at ?? session.created_at,
2486
- },
2487
- pinned: session.pinned === 1,
4038
+ diagnosis,
4039
+ nextActions: firstIssue?.suggestedActions[0]
4040
+ ? [{ code: firstIssue.code, message: firstIssue.suggestedActions[0] }]
4041
+ : [{ code: 'NO_DIAGNOSIS_ISSUES', message: 'No diagnosis issues were found for the selected override run.' }],
2488
4042
  };
2489
4043
  },
2490
4044
  get_recent_events: async (input) => {
@@ -3822,7 +5376,7 @@ export function createV1ToolHandlers(getDb, getSessionConnectionState) {
3822
5376
  },
3823
5377
  };
3824
5378
  }
3825
- export function createV2ToolHandlers(captureClient) {
5379
+ export function createV2ToolHandlers(captureClient, getDb, getSessionConnectionState) {
3826
5380
  const capturePageState = async (sessionId, input) => {
3827
5381
  const maxItems = resolveStructuredMaxItems(input.maxItems, 40);
3828
5382
  const maxTextLength = resolveStructuredTextLength(input.maxTextLength, 80);
@@ -3845,6 +5399,420 @@ export function createV2ToolHandlers(captureClient) {
3845
5399
  };
3846
5400
  };
3847
5401
  return {
5402
+ observe_override_assets: async (input) => {
5403
+ const sessionId = getSessionId(input);
5404
+ if (!sessionId) {
5405
+ throw new Error('sessionId is required');
5406
+ }
5407
+ const tabId = resolveOptionalTabId(input.tabId);
5408
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_OBSERVE_ASSETS', { tabId, includePerformance: input.includePerformance !== false }, 5_000);
5409
+ const payload = ensureCaptureSuccess(capture, sessionId);
5410
+ const assetCount = Array.isArray(payload.assets) ? payload.assets.length : 0;
5411
+ const persisted = getDb
5412
+ ? persistObservedOverrideAssets(getDb(), { ...payload, sessionId, tabId: payload.tabId ?? tabId })
5413
+ : undefined;
5414
+ return {
5415
+ ...createBaseResponse(sessionId),
5416
+ limitsApplied: {
5417
+ maxResults: assetCount,
5418
+ truncated: capture.truncated ?? false,
5419
+ },
5420
+ persisted,
5421
+ ...payload,
5422
+ nextActions: assetCount > 0
5423
+ ? [{ code: 'MAP_NEXT_ASSETS', message: 'Run map_next_override_assets with projectRoot and sourcePaths to score override candidates.' }]
5424
+ : [{ code: 'LOAD_ROUTE', message: 'Load or interact with the target route so document, asset, and fetch resources are requested, then observe again.' }],
5425
+ };
5426
+ },
5427
+ capture_override_response_body: async (input) => {
5428
+ const sessionId = getSessionId(input);
5429
+ if (!sessionId) {
5430
+ throw new Error('sessionId is required');
5431
+ }
5432
+ const targetUrl = normalizeOptionalString(input.targetUrl) ?? normalizeOptionalString(input.targetAssetUrl);
5433
+ if (!targetUrl) {
5434
+ throw new Error('targetUrl is required');
5435
+ }
5436
+ assertOverrideResponseRequestCaptureSafe({
5437
+ requestMethod: input.requestMethod,
5438
+ requestHeaders: input.requestHeaders,
5439
+ subject: 'Response body capture request',
5440
+ });
5441
+ const tabId = resolveOptionalTabId(input.tabId);
5442
+ const timeoutMs = resolveTimeoutMs(input.timeoutMs, 10_000, 60_000);
5443
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_RESPONSE_BODY', {
5444
+ targetUrl,
5445
+ tabId,
5446
+ captureMode: normalizeOptionalString(input.captureMode),
5447
+ triggerReload: typeof input.triggerReload === 'boolean' ? input.triggerReload : undefined,
5448
+ matchMode: normalizeOptionalString(input.matchMode),
5449
+ requestMethod: input.requestMethod,
5450
+ requestHeaders: isRecord(input.requestHeaders) ? input.requestHeaders : undefined,
5451
+ timeoutMs,
5452
+ maxBodyBytes: input.maxBodyBytes,
5453
+ includeBody: input.includeBody === true,
5454
+ }, timeoutMs + 2_000);
5455
+ const payload = ensureCaptureSuccess(capture, sessionId);
5456
+ return {
5457
+ ...createBaseResponse(sessionId),
5458
+ limitsApplied: {
5459
+ maxResults: 1,
5460
+ truncated: capture.truncated ?? payload.truncated === true,
5461
+ },
5462
+ ...payload,
5463
+ nextActions: payload.bodyCaptured === true
5464
+ ? [{ code: 'PLAN_RESPONSE_PATCH', message: 'Run plan_override_response_patch with textPatches or jsonPatches to generate an exact response override.' }]
5465
+ : [{ code: 'UNSUPPORTED_RESPONSE_BODY', message: 'Only bounded text-like response bodies can be patched safely.' }],
5466
+ };
5467
+ },
5468
+ plan_override_response_patch: async (input) => {
5469
+ const sessionId = getSessionId(input);
5470
+ let plannerInput = input;
5471
+ let capturedFromLiveSession;
5472
+ const hasProvidedBody = typeof input.responseBodyText === 'string'
5473
+ || typeof input.bodyText === 'string'
5474
+ || typeof input.responseBodyBase64 === 'string'
5475
+ || typeof input.bodyBase64 === 'string';
5476
+ if (!hasProvidedBody && sessionId) {
5477
+ const targetUrl = normalizeOptionalString(input.targetUrl) ?? normalizeOptionalString(input.targetAssetUrl);
5478
+ if (!targetUrl) {
5479
+ throw new Error('targetUrl is required');
5480
+ }
5481
+ const tabId = resolveOptionalTabId(input.tabId);
5482
+ const timeoutMs = resolveTimeoutMs(input.timeoutMs, 10_000, 60_000);
5483
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_RESPONSE_BODY', {
5484
+ targetUrl,
5485
+ tabId,
5486
+ captureMode: normalizeOptionalString(input.captureMode),
5487
+ triggerReload: typeof input.triggerReload === 'boolean' ? input.triggerReload : undefined,
5488
+ matchMode: normalizeOptionalString(input.matchMode),
5489
+ requestMethod: input.requestMethod,
5490
+ requestHeaders: isRecord(input.requestHeaders) ? input.requestHeaders : undefined,
5491
+ timeoutMs,
5492
+ maxBodyBytes: input.maxBodyBytes,
5493
+ includeBody: true,
5494
+ }, timeoutMs + 2_000);
5495
+ const payload = ensureCaptureSuccess(capture, sessionId);
5496
+ if (payload.truncated === true) {
5497
+ throw new Error('Captured response body was truncated; increase maxBodyBytes before planning a patch.');
5498
+ }
5499
+ if (typeof payload.bodyText !== 'string') {
5500
+ throw new Error('Captured response did not include a text body that can be patched.');
5501
+ }
5502
+ plannerInput = {
5503
+ ...input,
5504
+ responseBodyText: payload.bodyText,
5505
+ contentType: input.contentType ?? payload.contentType,
5506
+ ruleType: input.ruleType ?? payload.ruleType,
5507
+ requestMethod: input.requestMethod ?? payload.requestMethod,
5508
+ captureMode: input.captureMode ?? payload.captureMode,
5509
+ source: payload.source,
5510
+ requestHeaders: payload.requestHeaders,
5511
+ };
5512
+ const variantContext = buildOverrideVariantContext({
5513
+ targetUrl: payload.targetUrl,
5514
+ requestMethod: input.requestMethod ?? payload.requestMethod,
5515
+ matchMode: payload.matchMode,
5516
+ ruleType: input.ruleType ?? payload.ruleType,
5517
+ captureMode: payload.captureMode,
5518
+ source: payload.source,
5519
+ triggerReload: payload.triggerReload,
5520
+ requestHeaders: payload.requestHeaders,
5521
+ });
5522
+ capturedFromLiveSession = {
5523
+ sessionId,
5524
+ targetUrl: payload.targetUrl,
5525
+ requestMethod: input.requestMethod ?? payload.requestMethod,
5526
+ statusCode: payload.statusCode,
5527
+ contentType: payload.contentType,
5528
+ bodyBytes: payload.bodyBytes,
5529
+ capturedBytes: payload.capturedBytes,
5530
+ truncated: payload.truncated === true,
5531
+ ruleType: payload.ruleType,
5532
+ matchMode: payload.matchMode,
5533
+ captureMode: payload.captureMode,
5534
+ source: payload.source,
5535
+ tabId: payload.tabId,
5536
+ triggerReload: payload.triggerReload,
5537
+ requestHeaders: payload.requestHeaders,
5538
+ variantContext,
5539
+ };
5540
+ }
5541
+ const plan = planOverrideResponsePatch(plannerInput);
5542
+ const variantContext = buildOverrideVariantContext({
5543
+ targetUrl: plan.targetUrl,
5544
+ requestMethod: plan.requestMethod,
5545
+ matchMode: plan.matchMode,
5546
+ ruleType: plan.ruleType,
5547
+ captureMode: plannerInput.captureMode,
5548
+ source: plannerInput.source,
5549
+ triggerReload: plannerInput.triggerReload,
5550
+ requestHeaders: plannerInput.requestHeaders,
5551
+ });
5552
+ const auditPlan = getDb
5553
+ ? persistResponsePlanAudit({
5554
+ db: getDb(),
5555
+ sessionId,
5556
+ input,
5557
+ plan,
5558
+ capturedFromLiveSession,
5559
+ variantContext,
5560
+ })
5561
+ : undefined;
5562
+ return {
5563
+ ...createBaseResponse(sessionId),
5564
+ limitsApplied: {
5565
+ maxResults: plan.rule ? 1 : 0,
5566
+ truncated: false,
5567
+ },
5568
+ capturedFromLiveSession,
5569
+ variantContext,
5570
+ audit: {
5571
+ persisted: auditPlan !== undefined,
5572
+ plans: auditPlan ? [auditPlan] : [],
5573
+ },
5574
+ ...plan,
5575
+ };
5576
+ },
5577
+ map_next_override_assets: async (input) => {
5578
+ const projectRoot = normalizeOptionalString(input.projectRoot);
5579
+ if (!projectRoot) {
5580
+ throw new Error('projectRoot is required');
5581
+ }
5582
+ const sessionId = getSessionId(input);
5583
+ let observedAssets = input.observedAssets;
5584
+ let observedFromLiveTab;
5585
+ let observedFromPersisted;
5586
+ if (!Array.isArray(observedAssets) && sessionId) {
5587
+ const tabId = resolveOptionalTabId(input.tabId);
5588
+ try {
5589
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_OBSERVE_ASSETS', { tabId, includePerformance: true }, 5_000);
5590
+ observedFromLiveTab = ensureCaptureSuccess(capture, sessionId);
5591
+ observedAssets = observedFromLiveTab.assets;
5592
+ if (getDb) {
5593
+ persistObservedOverrideAssets(getDb(), { ...observedFromLiveTab, sessionId, tabId: observedFromLiveTab.tabId ?? tabId });
5594
+ }
5595
+ }
5596
+ catch (error) {
5597
+ if (!getDb || !isLiveSessionDisconnectedError(error)) {
5598
+ throw error;
5599
+ }
5600
+ observedAssets = listObservedOverrideAssets(getDb(), { sessionId });
5601
+ observedFromPersisted = { sessionId, assetCount: Array.isArray(observedAssets) ? observedAssets.length : 0 };
5602
+ }
5603
+ }
5604
+ const mapping = await mapNextOverrideAssetsWithDrift({
5605
+ projectRoot,
5606
+ nextDir: normalizeOptionalString(input.nextDir),
5607
+ observedAssets,
5608
+ sourcePaths: input.sourcePaths,
5609
+ route: input.route,
5610
+ maxResults: input.maxResults,
5611
+ fetchProductionAssets: input.fetchProductionAssets,
5612
+ productionFetchTimeoutMs: input.productionFetchTimeoutMs,
5613
+ maxProductionAssetBytes: input.maxProductionAssetBytes,
5614
+ maxDriftCandidates: input.maxDriftCandidates,
5615
+ productionFetchConcurrency: input.productionFetchConcurrency,
5616
+ });
5617
+ return {
5618
+ ...createBaseResponse(sessionId),
5619
+ limitsApplied: {
5620
+ maxResults: mapping.candidates.length,
5621
+ truncated: false,
5622
+ },
5623
+ observedFromLiveTab: observedFromLiveTab
5624
+ ? {
5625
+ pageUrl: observedFromLiveTab.pageUrl,
5626
+ tabId: observedFromLiveTab.tabId,
5627
+ assetCount: Array.isArray(observedFromLiveTab.assets) ? observedFromLiveTab.assets.length : 0,
5628
+ }
5629
+ : undefined,
5630
+ observedFromPersisted,
5631
+ ...mapping,
5632
+ };
5633
+ },
5634
+ plan_next_source_override: async (input) => {
5635
+ const projectRoot = normalizeOptionalString(input.projectRoot);
5636
+ if (!projectRoot) {
5637
+ throw new Error('projectRoot is required');
5638
+ }
5639
+ const sessionId = getSessionId(input);
5640
+ let observedAssets = input.observedAssets;
5641
+ let observedFromLiveTab;
5642
+ let observedFromPersisted;
5643
+ if (!Array.isArray(observedAssets) && sessionId) {
5644
+ const tabId = resolveOptionalTabId(input.tabId);
5645
+ try {
5646
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_OBSERVE_ASSETS', { tabId, includePerformance: true }, 5_000);
5647
+ observedFromLiveTab = ensureCaptureSuccess(capture, sessionId);
5648
+ observedAssets = observedFromLiveTab.assets;
5649
+ if (getDb) {
5650
+ persistObservedOverrideAssets(getDb(), { ...observedFromLiveTab, sessionId, tabId: observedFromLiveTab.tabId ?? tabId });
5651
+ }
5652
+ }
5653
+ catch (error) {
5654
+ if (!getDb || !isLiveSessionDisconnectedError(error)) {
5655
+ throw error;
5656
+ }
5657
+ observedAssets = listObservedOverrideAssets(getDb(), { sessionId });
5658
+ observedFromPersisted = { sessionId, assetCount: Array.isArray(observedAssets) ? observedAssets.length : 0 };
5659
+ }
5660
+ }
5661
+ const plan = await planNextSourceOverride({
5662
+ projectRoot,
5663
+ nextDir: normalizeOptionalString(input.nextDir),
5664
+ observedAssets,
5665
+ sourceEdits: input.sourceEdits,
5666
+ sourcePaths: input.sourcePaths,
5667
+ route: input.route,
5668
+ configPath: input.configPath,
5669
+ writeConfig: input.writeConfig,
5670
+ overwrite: input.overwrite,
5671
+ enabled: input.enabled,
5672
+ profileEnabled: input.profileEnabled,
5673
+ autoReload: input.autoReload,
5674
+ profileId: input.profileId,
5675
+ profileName: input.profileName,
5676
+ buildTimeoutMs: input.buildTimeoutMs,
5677
+ maxRules: input.maxRules,
5678
+ fetchProductionAssets: input.fetchProductionAssets,
5679
+ productionFetchTimeoutMs: input.productionFetchTimeoutMs,
5680
+ maxProductionAssetBytes: input.maxProductionAssetBytes,
5681
+ maxDriftCandidates: input.maxDriftCandidates,
5682
+ productionFetchConcurrency: input.productionFetchConcurrency,
5683
+ overlayTtlMs: input.overlayTtlMs,
5684
+ });
5685
+ const auditPlans = getDb
5686
+ ? persistNextSourcePlanAudits({
5687
+ db: getDb(),
5688
+ sessionId,
5689
+ input,
5690
+ plan,
5691
+ })
5692
+ : [];
5693
+ return {
5694
+ ...createBaseResponse(sessionId),
5695
+ limitsApplied: {
5696
+ maxResults: plan.rules.length,
5697
+ truncated: false,
5698
+ },
5699
+ observedFromLiveTab: observedFromLiveTab
5700
+ ? {
5701
+ pageUrl: observedFromLiveTab.pageUrl,
5702
+ tabId: observedFromLiveTab.tabId,
5703
+ assetCount: Array.isArray(observedFromLiveTab.assets) ? observedFromLiveTab.assets.length : 0,
5704
+ }
5705
+ : undefined,
5706
+ observedFromPersisted,
5707
+ audit: {
5708
+ persisted: auditPlans.length > 0,
5709
+ plans: auditPlans,
5710
+ },
5711
+ ...plan,
5712
+ };
5713
+ },
5714
+ get_override_status: async (input) => {
5715
+ const sessionId = getSessionId(input);
5716
+ if (!sessionId) {
5717
+ throw new Error('sessionId is required');
5718
+ }
5719
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_POC_GET_STATUS', {}, 3_000);
5720
+ const payload = ensureCaptureSuccess(capture, sessionId);
5721
+ return {
5722
+ ...createBaseResponse(sessionId),
5723
+ limitsApplied: {
5724
+ maxResults: 1,
5725
+ truncated: capture.truncated ?? false,
5726
+ },
5727
+ preflight: getDb
5728
+ ? buildOverridePreflight({
5729
+ db: getDb(),
5730
+ sessionId,
5731
+ profileId: input.profileId,
5732
+ getSessionConnectionState,
5733
+ })
5734
+ : null,
5735
+ ...payload,
5736
+ nextActions: payload.lastErrorCode
5737
+ ? [{ code: 'DIAGNOSE_OVERRIDES', message: 'Run diagnose_overrides for the latest override failure.' }]
5738
+ : payload.active === true
5739
+ ? [{ code: 'GET_OVERRIDE_REQUEST_LOG', message: 'Inspect get_override_request_log after the target tab loads matching assets.' }]
5740
+ : [{ code: 'ENABLE_OVERRIDES', message: 'Enable overrides after validating the selected profile.' }],
5741
+ };
5742
+ },
5743
+ preflight_overrides: async (input) => {
5744
+ const sessionId = getSessionId(input);
5745
+ if (!sessionId) {
5746
+ throw new Error('sessionId is required');
5747
+ }
5748
+ if (!getDb) {
5749
+ throw new Error('preflight_overrides requires database-backed override state');
5750
+ }
5751
+ return {
5752
+ ...createBaseResponse(sessionId),
5753
+ ...buildOverridePreflight({
5754
+ db: getDb(),
5755
+ sessionId,
5756
+ profileId: input.profileId,
5757
+ getSessionConnectionState,
5758
+ }),
5759
+ };
5760
+ },
5761
+ enable_overrides: async (input) => {
5762
+ const sessionId = getSessionId(input);
5763
+ if (!sessionId) {
5764
+ throw new Error('sessionId is required');
5765
+ }
5766
+ const preflight = getDb
5767
+ ? buildOverridePreflight({
5768
+ db: getDb(),
5769
+ sessionId,
5770
+ profileId: input.profileId,
5771
+ getSessionConnectionState,
5772
+ })
5773
+ : null;
5774
+ if (preflight && preflight.ready !== true) {
5775
+ const blockingCodes = Array.isArray(preflight.issues)
5776
+ ? preflight.issues
5777
+ .filter((issue) => isRecord(issue) && issue.severity === 'error')
5778
+ .map((issue) => String(issue.code ?? 'UNKNOWN'))
5779
+ : [];
5780
+ const profile = isRecord(preflight.profile) ? preflight.profile : {};
5781
+ if (!canBypassPreflightForExperimentalRsc(profile, blockingCodes)) {
5782
+ throw new Error(`Override preflight failed: ${blockingCodes.join(', ') || 'UNKNOWN'}`);
5783
+ }
5784
+ }
5785
+ const tabId = resolveOptionalTabId(input.tabId);
5786
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_POC_ENABLE', { tabId }, 8_000);
5787
+ const payload = ensureCaptureSuccess(capture, sessionId);
5788
+ return {
5789
+ ...createBaseResponse(sessionId),
5790
+ limitsApplied: {
5791
+ maxResults: 1,
5792
+ truncated: capture.truncated ?? false,
5793
+ },
5794
+ preflight,
5795
+ ...payload,
5796
+ nextActions: [{ code: 'RELOAD_OR_INTERACT', message: 'Reload or interact with the tab so configured asset requests occur under the active override.' }],
5797
+ };
5798
+ },
5799
+ disable_overrides: async (input) => {
5800
+ const sessionId = getSessionId(input);
5801
+ if (!sessionId) {
5802
+ throw new Error('sessionId is required');
5803
+ }
5804
+ const capture = await executeLiveCapture(captureClient, sessionId, 'CAPTURE_OVERRIDE_POC_DISABLE', {}, 5_000);
5805
+ const payload = ensureCaptureSuccess(capture, sessionId);
5806
+ return {
5807
+ ...createBaseResponse(sessionId),
5808
+ limitsApplied: {
5809
+ maxResults: 1,
5810
+ truncated: capture.truncated ?? false,
5811
+ },
5812
+ ...payload,
5813
+ nextActions: [{ code: 'VERIFY_DISABLED', message: 'Run get_override_status if you need to confirm the debugger override is inactive.' }],
5814
+ };
5815
+ },
3848
5816
  get_dom_subtree: async (input) => {
3849
5817
  const sessionId = getSessionId(input);
3850
5818
  if (!sessionId) {
@@ -4522,7 +6490,9 @@ export async function routeToolCall(tools, toolName, input) {
4522
6490
  }
4523
6491
  export function createMCPServer(overrides = {}, options = {}) {
4524
6492
  const logger = options.logger ?? createDefaultMcpLogger();
4525
- const v2Handlers = options.captureClient ? createV2ToolHandlers(options.captureClient) : {};
6493
+ const v2Handlers = options.captureClient
6494
+ ? createV2ToolHandlers(options.captureClient, () => getConnection().db, options.getSessionConnectionState)
6495
+ : {};
4526
6496
  const tools = createToolRegistry({
4527
6497
  ...createV1ToolHandlers(() => getConnection().db, options.getSessionConnectionState),
4528
6498
  ...v2Handlers,