[GH-ISSUE #6555] [Bug] Dropped WebSocket frames can produce a false payload modification #3945

Closed
opened 2026-10-31 00:45:28 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 23, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6555

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

The offline compare-web-captures CLI reports network.status: "changed" and a modified wss_ identity when both captures explicitly have dropped WebSocket frames and their retained same-opcode frames have different payload sizes. The retained positions need not refer to the same real frame. Both windows can contain the identical ordered stream with sizes [1, 2]: retaining only its first frame on one side and only its second frame on the other completely explains the observed difference without a website change.

Reproduction

  1. With the installed package from REA version already available, save the following generator as fixture.mjs in an empty disposable directory. Pass its package root as the argument; REA_PACKAGE_ROOT below denotes that absolute package directory. All URLs are inert synthetic identities.
  2. Run node fixture.mjs "$REA_PACKAGE_ROOT" in that directory, then node "$REA_PACKAGE_ROOT/scripts/rea.mjs" compare-web-captures before.json after.json --json.
  3. Inspect the indicated dimension under normalized_result.dimensions.

The equivalent fixture generation, production-schema/domain comparison, and public CLI command were executed locally. Each failing CLI comparison ran once and exited 0 with empty stderr. No live browser capture was attempted.

import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const root = resolve(process.argv[2]);
const load = p => import(pathToFileURL(`${root}/dist/${p}`).href);
const t = new Date(0).toISOString();
const base = () => ({
  browser: { product: 'fixture', protocol_version: '1.3', revision: 'fixture', user_agent: 'fixture', js_version: 'fixture' },
  target: { target_id: 'page', type: 'page', title: 'Fixture', url: 'https://app.example.test/', origin: 'https://app.example.test', attached: false },
  capture_window: { started_at: t, ended_at: t, observation_ms: 0 },
  completeness: { status: 'complete_within_window', conditions: ['complete_within_window'], policy_filtered_sections: [], attach_limited_sections: [], truncated_sections: [], unavailable_sections: [], excluded: [], dropped_events: { scripts: 0, network_requests: 0, console_events: 0, websocket_connections: 0, websocket_frames: 0, webmcp_tools: 0, timeline_events: 0, total: 0 } },
  frames: [], dom: { total_nodes: 0, nodes: [] },
  accessibility: { total_nodes: 0, nodes: [], text_capture: { status: 'not_approved', retained_bytes: 0, excluded_fields: 0 } },
  scripts: { total: 0, items: [] }, resources: [], workers: [],
  network: { requests: [], websocket_connections: [], coverage_started_at: t, prior_activity_available: false },
  console: { events: [], coverage_started_at: t, prior_activity_available: false },
  metadata: { responses: [], dom_urls: [], agent_hints: [], excluded_dom_urls: 0, headers_allowlisted: true },
  storage: { origin: 'https://app.example.test', usage_bytes: null, quota_bytes: null, local_storage_keys: [], session_storage_keys: [], indexed_db_names: [], cache_names: [], values_redacted: true },
  limitations: ['Synthetic normalized capture; no live browser.'],
});
const { classifyBrowserCompleteness } = await load('domain/browserCompleteness.js');
const capture = payloadBytes => {
  const c = base();
  c.network.websocket_connections = [{ request_id: 'local', url: 'wss://app.example.test/live', events: [{ direction: 'received', opcode: 1, payload_bytes: payloadBytes, payload_shape: null }] }];
  c.completeness = classifyBrowserCompleteness({ policyFilteredSections: new Set(), attachLimitedSections: new Set(), truncatedSections: new Set(['websocket_frames']), unavailableSections: new Set(), excluded: [], droppedEvents: { ...c.completeness.dropped_events, websocket_frames: 1 } });
  return c;
};
const before = capture(1), after = capture(2);
for (const [name, capture] of [['before', before], ['after', after]])
  writeFileSync(`${name}.json`, JSON.stringify({ inspection: capture }));

Expected behavior

The result should preserve unknown comparability when omitted frames can explain the entire difference. An observed difference requires comparable observations. The comparison contract says changed proves an observed difference, and the documented coverage rule makes dropped WebSocket frames limit inventory completeness. The production comparator itself computes WebSocket inventory completeness before constructing payload maps.

Impact and scope

One published-runtime CLI comparison produced a false definite modification from the omission-only fixture. The production-boundary same-input control returned network.status: "unknown", so changing only which partial frame was retained promotes uncertainty to a definite change. Actual live-provider frequency is unknown. HTTP requests and browser scenarios are outside this finding.

REA version

Published npm package rea-agents 6.7.0; relevant current-source paths were also inspected at 6c4d880f2dd9ca7d42593be916f90ec19b8a09d7.

Runtime and platform

Node.js v26.12.1; Linux x64. No Hopper, Ghidra, browser, network service, or device was used.

Target and artifact context

Two synthetic normalized passive browser captures; each has one WebSocket connection, one retained received text frame, one dropped WebSocket frame, and an explicit websocket_frames truncation marker.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed public CLI result, obtained using only local JSON files:

{
  "status": "changed",
  "total_changes": 1,
  "changes": [
    {
      "identity": "wss_37c95debe1553272872c3dbcc24a5b63c901d6bd3b7b0abdee1afd85700133fd",
      "change": "modified"
    }
  ],
  "reason": null
}

The whole comparison also reported overall_status: "changed". The production-boundary self-comparison returned zero changes and unknown coverage. No post-draft rerun was performed.

Analysis or suspected cause

Source-derived: payload comparison is invoked and its maps are included regardless of webSocketInventoryComplete. Payload grouping and comparison align retained event positions by direction/opcode stream and connection count; they receive no omission ledger. This permits different surviving positions to be interpreted as a modified payload.

Additional context

Related HTTP issue #6523 concerns repeated HTTP response-fact groups. This fixture contains no HTTP request and reaches the independent WebSocket payload-position path.

Restoration check (not executed): the supplied omission-only pair must not report a definite network change; a complete pair whose corresponding frame sizes genuinely differ must remain capable of reporting a change.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The offline comparison emits a definite website-change result from incomplete observation facts on the bounded synthetic input; no target state is modified.
  • Workaround status: unverified
  • Workaround basis: Recapturing complete matching facts could avoid the affected input, but no live-provider recapture or workaround was executed.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6555 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior The offline `compare-web-captures` CLI reports `network.status: "changed"` and a modified `wss_` identity when both captures explicitly have dropped WebSocket frames and their retained same-opcode frames have different payload sizes. The retained positions need not refer to the same real frame. Both windows can contain the identical ordered stream with sizes `[1, 2]`: retaining only its first frame on one side and only its second frame on the other completely explains the observed difference without a website change. ### Reproduction 1. With the installed package from REA version already available, save the following generator as `fixture.mjs` in an empty disposable directory. Pass its package root as the argument; `REA_PACKAGE_ROOT` below denotes that absolute package directory. All URLs are inert synthetic identities. 2. Run `node fixture.mjs "$REA_PACKAGE_ROOT"` in that directory, then `node "$REA_PACKAGE_ROOT/scripts/rea.mjs" compare-web-captures before.json after.json --json`. 3. Inspect the indicated dimension under `normalized_result.dimensions`. The equivalent fixture generation, production-schema/domain comparison, and public CLI command were executed locally. Each failing CLI comparison ran once and exited 0 with empty stderr. No live browser capture was attempted. ```javascript import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const root = resolve(process.argv[2]); const load = p => import(pathToFileURL(`${root}/dist/${p}`).href); const t = new Date(0).toISOString(); const base = () => ({ browser: { product: 'fixture', protocol_version: '1.3', revision: 'fixture', user_agent: 'fixture', js_version: 'fixture' }, target: { target_id: 'page', type: 'page', title: 'Fixture', url: 'https://app.example.test/', origin: 'https://app.example.test', attached: false }, capture_window: { started_at: t, ended_at: t, observation_ms: 0 }, completeness: { status: 'complete_within_window', conditions: ['complete_within_window'], policy_filtered_sections: [], attach_limited_sections: [], truncated_sections: [], unavailable_sections: [], excluded: [], dropped_events: { scripts: 0, network_requests: 0, console_events: 0, websocket_connections: 0, websocket_frames: 0, webmcp_tools: 0, timeline_events: 0, total: 0 } }, frames: [], dom: { total_nodes: 0, nodes: [] }, accessibility: { total_nodes: 0, nodes: [], text_capture: { status: 'not_approved', retained_bytes: 0, excluded_fields: 0 } }, scripts: { total: 0, items: [] }, resources: [], workers: [], network: { requests: [], websocket_connections: [], coverage_started_at: t, prior_activity_available: false }, console: { events: [], coverage_started_at: t, prior_activity_available: false }, metadata: { responses: [], dom_urls: [], agent_hints: [], excluded_dom_urls: 0, headers_allowlisted: true }, storage: { origin: 'https://app.example.test', usage_bytes: null, quota_bytes: null, local_storage_keys: [], session_storage_keys: [], indexed_db_names: [], cache_names: [], values_redacted: true }, limitations: ['Synthetic normalized capture; no live browser.'], }); const { classifyBrowserCompleteness } = await load('domain/browserCompleteness.js'); const capture = payloadBytes => { const c = base(); c.network.websocket_connections = [{ request_id: 'local', url: 'wss://app.example.test/live', events: [{ direction: 'received', opcode: 1, payload_bytes: payloadBytes, payload_shape: null }] }]; c.completeness = classifyBrowserCompleteness({ policyFilteredSections: new Set(), attachLimitedSections: new Set(), truncatedSections: new Set(['websocket_frames']), unavailableSections: new Set(), excluded: [], droppedEvents: { ...c.completeness.dropped_events, websocket_frames: 1 } }); return c; }; const before = capture(1), after = capture(2); for (const [name, capture] of [['before', before], ['after', after]]) writeFileSync(`${name}.json`, JSON.stringify({ inspection: capture })); ``` ### Expected behavior The result should preserve unknown comparability when omitted frames can explain the entire difference. An observed difference requires comparable observations. The [comparison contract](https://github.com/morluto/rea/blob/6c4d880f2dd9ca7d42593be916f90ec19b8a09d7/src/domain/webCaptureDiff.ts#L43-L48) says changed proves an observed difference, and the [documented coverage rule](https://github.com/morluto/rea/blob/6c4d880f2dd9ca7d42593be916f90ec19b8a09d7/docs/browser-observation.md#L347-L366) makes dropped WebSocket frames limit inventory completeness. The production comparator itself computes WebSocket inventory completeness before constructing payload maps. ### Impact and scope One published-runtime CLI comparison produced a false definite modification from the omission-only fixture. The production-boundary same-input control returned `network.status: "unknown"`, so changing only which partial frame was retained promotes uncertainty to a definite change. Actual live-provider frequency is unknown. HTTP requests and browser scenarios are outside this finding. ### REA version Published npm package `rea-agents` 6.7.0; relevant current-source paths were also inspected at `6c4d880f2dd9ca7d42593be916f90ec19b8a09d7`. ### Runtime and platform Node.js v26.12.1; Linux x64. No Hopper, Ghidra, browser, network service, or device was used. ### Target and artifact context Two synthetic normalized passive browser captures; each has one WebSocket connection, one retained received text frame, one dropped WebSocket frame, and an explicit `websocket_frames` truncation marker. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed public CLI result, obtained using only local JSON files: ```json { "status": "changed", "total_changes": 1, "changes": [ { "identity": "wss_37c95debe1553272872c3dbcc24a5b63c901d6bd3b7b0abdee1afd85700133fd", "change": "modified" } ], "reason": null } ``` The whole comparison also reported `overall_status: "changed"`. The production-boundary self-comparison returned zero changes and unknown coverage. No post-draft rerun was performed. ### Analysis or suspected cause Source-derived: [payload comparison is invoked and its maps are included regardless of `webSocketInventoryComplete`](https://github.com/morluto/rea/blob/6c4d880f2dd9ca7d42593be916f90ec19b8a09d7/src/domain/webCaptureDiff.ts#L464-L491). [Payload grouping and comparison](https://github.com/morluto/rea/blob/6c4d880f2dd9ca7d42593be916f90ec19b8a09d7/src/domain/webCaptureDiff.ts#L638-L701) align retained event positions by direction/opcode stream and connection count; they receive no omission ledger. This permits different surviving positions to be interpreted as a modified payload. ### Additional context Related HTTP issue [#6523](https://github.com/morluto/rea/issues/6523) concerns repeated HTTP response-fact groups. This fixture contains no HTTP request and reaches the independent WebSocket payload-position path. Restoration check (not executed): the supplied omission-only pair must not report a definite network change; a complete pair whose corresponding frame sizes genuinely differ must remain capable of reporting a change. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The offline comparison emits a definite website-change result from incomplete observation facts on the bounded synthetic input; no target state is modified. - Workaround status: unverified - Workaround basis: Recapturing complete matching facts could avoid the affected input, but no live-provider recapture or workaround was executed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3945
No description provided.