[GH-ISSUE #3986] [Bug] Web bundle analysis drops an empty-string WebMCP input property #1580

Closed
opened 2026-10-16 21:07:24 +00:00 by bsakaguchi · 2 comments
Owner

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

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

analyze-web-bundle returns an incomplete schema_property_names inventory for a static WebMCP declaration whose literal input properties include "". The CLI retains an ordinary sibling field and the declaration's untrusted status, but silently drops the empty-string field. This can mislead a caller reconstructing the declared input object.

The executed recipe produced these results. Every analysis call exited successfully. The literal empty-string property disappeared, while the ordinary sibling remained. The raw-source inspection retained the exact declaration text.

{"label":"literal","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"}
{"rawSourceRetained":true}
{"label":"ordinary","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"}
{"label":"unresolved","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"}

An additional CLI run without type: "object" reproduced the same omission. These observations establish the behavior through the repository's simulated CDP provider, not through real Chrome. The captured JavaScript was analyzed statically.

Reproduction

Use the baseline under Runtime or environment with its locked dependencies installed and compiled CLI already built. Run this recipe from the repository root. It copies the repository's fake CDP implementation into a disposable directory, supplies the script text through the real HTTP/WebSocket protocol, and invokes the public CLI with an isolated home directory. It does not require Chrome or an external service.

node --input-type=module <<'JS'
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile, symlink, rm } from "node:fs/promises";
import { stripTypeScriptTypes } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";

const run = promisify(execFile);
const repo = process.cwd();
const work = await mkdtemp(join(tmpdir(), "webmcp-property-"));
try {
  await mkdir(join(work, "home"));
  for (const name of ["fakeCdpBrowser", "fakeCdpBrowserEvents", "fakeCdpBrowserResults", "fakeCdpBrowserTargets"]) {
    const text = await readFile(`tests/fixtures/${name}.ts`, "utf8");
    await writeFile(join(work, `${name}.js`), stripTypeScriptTypes(text));
  }
  await writeFile(join(work, "package.json"), '{"type":"module"}');
  await symlink(join(repo, "node_modules"), join(work, "node_modules"), "dir");
  const { startFakeCdpBrowser } = await import(pathToFileURL(join(work, "fakeCdpBrowser.js")).href);
  const cases = {
    literal: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{"":{type:"string"},ordinary:{type:"string"}}}});',
    ordinary: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{ordinary:{type:"string"}}}});',
    unresolved: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{[unknownKey]:{type:"string"},ordinary:{type:"string"}}}});'
  };
  for (const [label, scriptSource] of Object.entries(cases)) {
    const fake = await startFakeCdpBrowser({
      commandResult: command => command.method === "Debugger.getScriptSource"
        ? { scriptSource } : undefined
    });
    try {
      const options = { cwd: repo, env: { ...process.env, HOME: join(work, "home") }, maxBuffer: 20_000_000 };
      const { stdout } = await run(process.execPath, ["scripts/rea.mjs", "analyze-web-bundle", fake.endpoint, "allowed-page", "--observation-ms", "0", "--json"], options);
      const result = JSON.parse(stdout).normalized_result;
      console.log(JSON.stringify({ label, exit: 0,
        declarations: result.observations.webmcp_declarations.map(({ name, schema_property_names, trust }) => ({ name, schema_property_names, trust })),
        completeness: result.completeness.status }));
      if (label === "literal") {
        const capture = await run(process.execPath, ["scripts/rea.mjs", "inspect-web-page", fake.endpoint, "allowed-page", "--observation-ms", "0", "--include-script-sources", "--json"], options);
        const scripts = JSON.parse(capture.stdout).normalized_result.scripts.items;
        console.log(JSON.stringify({ rawSourceRetained: scripts.some(item => item.source?.artifact?.text === scriptSource) }));
      }
    } finally {
      await fake.close();
    }
  }
} finally {
  await rm(work, { recursive: true, force: true });
}
JS

The literal case declares both an empty-string input key and ordinary. The other cases discriminate between ordinary literal keys and unresolved computed keys. The inspection call also checks a manual recovery path for the literal source.

Expected behavior

Static WebMCP metadata should preserve the literal input property names. The literal case should return schema_property_names: ["", "ordinary"]. The ordinary and unresolved controls should each return ["ordinary"]; analysis must not turn an unresolved computed key into an observed empty-string key. The declaration should retain its name, source location, and page-declared-untrusted status.

The documented static-declaration workflow and evidence-preservation rules apply to this literal properties object. A known empty key is a source value, distinct from an unknown key.

Impact and scope

A successful static-analysis result silently loses one valid declared input field for this bounded syntax case. This changes the metadata an analyst uses to reconstruct the input object, but the reproduction shows no page execution, persistent mutation, or security-boundary crossing.

Workaround status: available. The executed inspect-web-page --include-script-sources call retained the exact declaration in the captured script text, allowing manual recovery of the empty-string property for the tested input.

REA version

rea 6.5.0 at source commit 4692bb3330

Runtime and platform

Node 24.18.0; macOS arm64

Target and artifact context

Static JavaScript WebMCP declaration delivered through the repository fake CDP HTTP/WebSocket provider; no real Chrome run.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

  • Expected source: AGENTS.md:13, AGENTS.md:19, and AGENTS.md:29 require preserving evidence, metadata, omission, and source values; docs/browser-observation.md:19 and src/contracts/browserToolContracts.ts:174 define static WebMCP declaration analysis; src/domain/webBundleAnalyzer.test.ts:100 identifies literal input-property names as the inventory contents; src/domain/javascript/javascriptAstValues.ts:76 explicitly preserves legal empty-string keys separately from unknown keys.
  • Failure source: The public CLI reproduction above returns ["ordinary"] for the literal case. Source-derived explanation at src/domain/webBundleAnalyzerInspection.ts:309 shows the property-name projection removing the empty string after the compatibility helper has collapsed empty and unresolved keys to that value.

Source inspection explains the observed result; it is separate from the executed CLI evidence. The existing focused analyzer tests passed, with 37 tests across two files, and did not cover this failure. The successful analysis also retained the declaration's source location and recorded one parsed script with zero parse failures.

Typed outcome or recovery expected

Retain schema_property_names: ["", "ordinary"] for the literal case and only ["ordinary"] for the unresolved-key control.

Analysis or suspected cause

Source-derived: src/domain/webBundleAnalyzerInspection.ts:309-314 uses semanticStaticPropertyName, which maps empty and unresolved keys to the same empty string, followed by filter(Boolean).

Additional context

Freshness check: the cited failing implementation is unchanged on origin/main at fc712b3750. The only change to a cited reproduction entry point normalizes global CLI flag syntax. The baseline reproduction was not rerun on that revision.

Restoration check: Rerun the recipe. The literal case must retain both "" and ordinary, while the ordinary and unresolved controls must continue to return only ordinary. Preserve the declaration's location and untrusted provenance. Keep the raw-source recovery check true. These checks restore the same metadata contract without treating unknown fields as observed empty-string fields.

Related #3331 and PR #3765 concern schema keywords being reported as input names when literal properties are absent. This report supplies literal properties or an explicit name. PR #3480 changes Electron option/callee consumers, and PR #2966 changes primitive key decoding; neither changes the WebMCP consumer responsible for this behavior.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3986 ### 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 `analyze-web-bundle` returns an incomplete `schema_property_names` inventory for a static WebMCP declaration whose literal input properties include `""`. The CLI retains an ordinary sibling field and the declaration's untrusted status, but silently drops the empty-string field. This can mislead a caller reconstructing the declared input object. The executed recipe produced these results. Every analysis call exited successfully. The literal empty-string property disappeared, while the ordinary sibling remained. The raw-source inspection retained the exact declaration text. ```json {"label":"literal","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"} {"rawSourceRetained":true} {"label":"ordinary","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"} {"label":"unresolved","exit":0,"declarations":[{"name":"catalog","schema_property_names":["ordinary"],"trust":"page-declared-untrusted"}],"completeness":"complete"} ``` An additional CLI run without `type: "object"` reproduced the same omission. These observations establish the behavior through the repository's simulated CDP provider, not through real Chrome. The captured JavaScript was analyzed statically. ### Reproduction Use the baseline under Runtime or environment with its locked dependencies installed and compiled CLI already built. Run this recipe from the repository root. It copies the repository's fake CDP implementation into a disposable directory, supplies the script text through the real HTTP/WebSocket protocol, and invokes the public CLI with an isolated home directory. It does not require Chrome or an external service. ```sh node --input-type=module <<'JS' import { execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, writeFile, symlink, rm } from "node:fs/promises"; import { stripTypeScriptTypes } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; const run = promisify(execFile); const repo = process.cwd(); const work = await mkdtemp(join(tmpdir(), "webmcp-property-")); try { await mkdir(join(work, "home")); for (const name of ["fakeCdpBrowser", "fakeCdpBrowserEvents", "fakeCdpBrowserResults", "fakeCdpBrowserTargets"]) { const text = await readFile(`tests/fixtures/${name}.ts`, "utf8"); await writeFile(join(work, `${name}.js`), stripTypeScriptTypes(text)); } await writeFile(join(work, "package.json"), '{"type":"module"}'); await symlink(join(repo, "node_modules"), join(work, "node_modules"), "dir"); const { startFakeCdpBrowser } = await import(pathToFileURL(join(work, "fakeCdpBrowser.js")).href); const cases = { literal: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{"":{type:"string"},ordinary:{type:"string"}}}});', ordinary: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{ordinary:{type:"string"}}}});', unresolved: 'document.modelContext.registerTool({name:"catalog",inputSchema:{type:"object",properties:{[unknownKey]:{type:"string"},ordinary:{type:"string"}}}});' }; for (const [label, scriptSource] of Object.entries(cases)) { const fake = await startFakeCdpBrowser({ commandResult: command => command.method === "Debugger.getScriptSource" ? { scriptSource } : undefined }); try { const options = { cwd: repo, env: { ...process.env, HOME: join(work, "home") }, maxBuffer: 20_000_000 }; const { stdout } = await run(process.execPath, ["scripts/rea.mjs", "analyze-web-bundle", fake.endpoint, "allowed-page", "--observation-ms", "0", "--json"], options); const result = JSON.parse(stdout).normalized_result; console.log(JSON.stringify({ label, exit: 0, declarations: result.observations.webmcp_declarations.map(({ name, schema_property_names, trust }) => ({ name, schema_property_names, trust })), completeness: result.completeness.status })); if (label === "literal") { const capture = await run(process.execPath, ["scripts/rea.mjs", "inspect-web-page", fake.endpoint, "allowed-page", "--observation-ms", "0", "--include-script-sources", "--json"], options); const scripts = JSON.parse(capture.stdout).normalized_result.scripts.items; console.log(JSON.stringify({ rawSourceRetained: scripts.some(item => item.source?.artifact?.text === scriptSource) })); } } finally { await fake.close(); } } } finally { await rm(work, { recursive: true, force: true }); } JS ``` The `literal` case declares both an empty-string input key and `ordinary`. The other cases discriminate between ordinary literal keys and unresolved computed keys. The inspection call also checks a manual recovery path for the literal source. ### Expected behavior Static WebMCP metadata should preserve the literal input property names. The `literal` case should return `schema_property_names: ["", "ordinary"]`. The ordinary and unresolved controls should each return `["ordinary"]`; analysis must not turn an unresolved computed key into an observed empty-string key. The declaration should retain its name, source location, and `page-declared-untrusted` status. The documented static-declaration workflow and evidence-preservation rules apply to this literal `properties` object. A known empty key is a source value, distinct from an unknown key. ### Impact and scope A successful static-analysis result silently loses one valid declared input field for this bounded syntax case. This changes the metadata an analyst uses to reconstruct the input object, but the reproduction shows no page execution, persistent mutation, or security-boundary crossing. Workaround status: available. The executed `inspect-web-page --include-script-sources` call retained the exact declaration in the captured script text, allowing manual recovery of the empty-string property for the tested input. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860 ### Runtime and platform Node 24.18.0; macOS arm64 ### Target and artifact context Static JavaScript WebMCP declaration delivered through the repository fake CDP HTTP/WebSocket provider; no real Chrome run. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence - Expected source: `AGENTS.md:13`, `AGENTS.md:19`, and `AGENTS.md:29` require preserving evidence, metadata, omission, and source values; `docs/browser-observation.md:19` and `src/contracts/browserToolContracts.ts:174` define static WebMCP declaration analysis; `src/domain/webBundleAnalyzer.test.ts:100` identifies literal input-property names as the inventory contents; `src/domain/javascript/javascriptAstValues.ts:76` explicitly preserves legal empty-string keys separately from unknown keys. - Failure source: The public CLI reproduction above returns `["ordinary"]` for the literal case. Source-derived explanation at `src/domain/webBundleAnalyzerInspection.ts:309` shows the property-name projection removing the empty string after the compatibility helper has collapsed empty and unresolved keys to that value. Source inspection explains the observed result; it is separate from the executed CLI evidence. The existing focused analyzer tests passed, with 37 tests across two files, and did not cover this failure. The successful analysis also retained the declaration's source location and recorded one parsed script with zero parse failures. ### Typed outcome or recovery expected Retain schema_property_names: ["", "ordinary"] for the literal case and only ["ordinary"] for the unresolved-key control. ### Analysis or suspected cause Source-derived: src/domain/webBundleAnalyzerInspection.ts:309-314 uses semanticStaticPropertyName, which maps empty and unresolved keys to the same empty string, followed by filter(Boolean). ### Additional context Freshness check: the cited failing implementation is unchanged on origin/main at fc712b3750cbe16755c4f78c1f90fdf09fbaca31. The only change to a cited reproduction entry point normalizes global CLI flag syntax. The baseline reproduction was not rerun on that revision. Restoration check: Rerun the recipe. The literal case must retain both `""` and `ordinary`, while the ordinary and unresolved controls must continue to return only `ordinary`. Preserve the declaration's location and untrusted provenance. Keep the raw-source recovery check true. These checks restore the same metadata contract without treating unknown fields as observed empty-string fields. Related #3331 and PR #3765 concern schema keywords being reported as input names when literal properties are absent. This report supplies literal properties or an explicit name. PR #3480 changes Electron option/callee consumers, and PR #2966 changes primitive key decoding; neither changes the WebMCP consumer responsible for this behavior.
Author
Owner

@XinwangTao commented on GitHub (Oct 15, 2026):

Confirmed — this is a clean case of conflating "known empty" with "unknown". The literal empty-string key is a valid, exactly-known input property name (a source value, per javascriptAstValues.ts:76), so projecting it through semanticStaticPropertyName to the same empty string as unresolved keys, then filter(Boolean), silently drops it while the unresolved control only avoids the bug by not being observed. The fix should distinguish the two at the projection boundary: have semanticStaticPropertyName return a distinct sentinel (e.g. null) for genuinely unresolved keys instead of the empty string, and let the filter drop only that sentinel — that keeps ["", "ordinary"] for the literal case and ["ordinary"] for the unresolved control without treating unknown fields as observed empty keys. Worth adding a focused analyzer test for both spellings, since the existing 37 tests missed this boundary.

<!-- gh-comment-id:6189043162 --> @XinwangTao commented on GitHub (Oct 15, 2026): Confirmed — this is a clean case of conflating "known empty" with "unknown". The literal empty-string key is a valid, exactly-known input property name (a source value, per javascriptAstValues.ts:76), so projecting it through semanticStaticPropertyName to the same empty string as unresolved keys, then filter(Boolean), silently drops it while the unresolved control only avoids the bug by not being observed. The fix should distinguish the two at the projection boundary: have semanticStaticPropertyName return a distinct sentinel (e.g. null) for genuinely unresolved keys instead of the empty string, and let the filter drop only that sentinel — that keeps ["", "ordinary"] for the literal case and ["ordinary"] for the unresolved control without treating unknown fields as observed empty keys. Worth adding a focused analyzer test for both spellings, since the existing 37 tests missed this boundary.
Author
Owner

@coygeek commented on GitHub (Oct 18, 2026):

Addressed on main by github.com/morluto/rea@ebd5a82c43. WebMCP input-property projection now preserves exact empty-string keys and excludes only unresolved keys, so a literal empty input field remains in schema_property_names. The added regression assertions cover both literal/computed empty keys and unresolved keys. Verified by reviewing the current source, regression assertions and commit ancestry; I have not rerun the reproduction. Closing as addressed on main.

<!-- gh-comment-id:6244125804 --> @coygeek commented on GitHub (Oct 18, 2026): Addressed on main by https://github.com/morluto/rea/commit/ebd5a82c439f6deb3a06062225ce009eb03257cf. WebMCP input-property projection now preserves exact empty-string keys and excludes only unresolved keys, so a literal empty input field remains in schema_property_names. The added regression assertions cover both literal/computed empty keys and unresolved keys. Verified by reviewing the current source, regression assertions and commit ancestry; I have not rerun the reproduction. Closing as addressed on main.
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#1580
No description provided.