[GH-ISSUE #2669] fix(javascript): nested exposed bridge members lose their candidate call paths #970

Closed
opened 2026-10-15 18:30:00 +00:00 by bsakaguchi · 0 comments
Owner

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

Summary

An application can expose a nested function through contextBridge and have its IPC channel recovered and paired, yet lose the candidate call path for that member. The graph lists nested.write as an exposed member, but an exact api.nested.write feature trace returns no matches. A flat member using the same IPC call traces successfully.

Steps to reproduce

Use the source checkout and contributor toolchain listed under Runtime or environment, with its locked dependencies and compiled JavaScript analysis worker available. Save the following diagnostic test as src/application/javascript/bridgeMemberTrace.test.ts and run it from the repository root. It invokes the production application analysis and authenticated feature-trace boundaries on disposable two-file directories. The application files remain inert data. The four cases compare flat and nested exposure with a return-only handler and with a handler whose external terminal is statically recognizable.

npm run test:focused -- src/application/javascript/bridgeMemberTrace.test.ts
import { expect, it } from "vitest";
import { mkdir, writeFile, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
import { traceApplicationFeatureEvidence } from "./JavaScriptApplicationWorkflowService.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";

it("compares nested and flat bridge closures without executing the fixture", async () => {
  const root = await mkdtemp(join(tmpdir(), "bridge-members-"));
  try {
  for (const terminal of [false, true]) {
    for (const nested of [false, true]) {
      const mode = `${nested ? "nested" : "flat"}-${terminal ? "terminal" : "return"}`;
      const dir = join(root, mode);
      await mkdir(dir, { recursive: true });
      const member = 'write: (value) => ipcRenderer.invoke("nested:write", value)';
      const preload = `const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", { ${nested ? `nested: { ${member} }` : member} });`;
      const main = terminal
        ? 'const { ipcMain, clipboard } = require("electron"); ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));'
        : 'const { ipcMain } = require("electron"); ipcMain.handle("nested:write", (event, value) => value);';
      await writeFile(join(dir, "preload.js"), preload);
      await writeFile(join(dir, "main.js"), main);
      const facts = analyzeJavaScriptStaticSource(preload);
      console.log("static", JSON.stringify({ mode, status: facts.parse_status,
        members: facts.electron.context_bridge_apis?.map(x => x.members),
        paths: facts.electron.call_paths?.map(x => ({entry:x.entry,terminal:x.terminal,channel:x.channel})) }));
      const resources = new ArtifactResourceScope();
      try {
        const result = await analyzeJavaScriptApplication({input_path: dir, format: "directory", max_heap_mb: 128, analysis_timeout_ms: 10000}, resources);
        expect(result.ok).toBe(true);
        if (!result.ok) throw result.error;
        const application = result.value.normalized_result as any;
        const graph = application.graph;
        const expressionNodes = graph.nodes.filter((n: any) => n.kind === "javascript-expression");
        const entries = [...new Set(expressionNodes.flatMap((n: any) => n.observations.map((o: any) => o.properties.entry)))];
        const joins = graph.edges.filter((e: any) => e.evidence.extractor.operation === "join-electron-wrapper-paths");
        const seeds = nested ? ["api.nested.write", "nested.write"] : ["api.write", "write"];
        const traces = seeds.map(value => {
          const trace = traceApplicationFeatureEvidence({application: result.value, seed: {kind:"api",value,match:"exact",case_sensitive:true},direction:"outgoing"});
          expect(trace.ok).toBe(true);
          if (!trace.ok) throw trace.error;
          const t = trace.value.normalized_result as any;
          return {seed:value,matches:t.summary.matched_seeds,nodes:t.graph?.nodes.map((n:any)=>n.kind),joins:t.graph?.edges.filter((e:any)=>e.evidence.extractor.operation==="join-electron-wrapper-paths").length};
        });
        console.log("application", JSON.stringify({mode,members:graph.nodes.filter((n:any)=>n.kind==="context-bridge-api").flatMap((n:any)=>n.observations.map((o:any)=>o.properties.members)),entries,joins:joins.length,summary:application.summary,coverage:graph.coverage,traces}));
        expect(entries.includes(nested ? "api.nested.write" : "api.write")).toBe(!nested);
        expect(joins.length).toBe(!nested && terminal ? 1 : 0);
      } finally { await resources.close(); }
    }
  }
  } finally { await rm(root, { recursive: true, force: true }); }
}, 45000);

The diagnostic assertions record the current failing behavior, so the test passes while that behavior remains present. Delete the diagnostic test afterward. The reviewed run used the repository's focused source-test launcher; the outer npm command shown above is the documented equivalent invocation and was not separately executed.

Expected behavior

The nested member should retain a candidate path beginning at api.nested.write and ending at its ipcRenderer.invoke call, just as the flat member retains api.write. With the uniquely paired clipboard handler, an outgoing exact API trace should include the member path and the handler's candidate path through their wrapper join. The paths must retain their static candidate status; static reconstruction does not prove application execution.

Actual behavior

All four application analyses succeeded and reported one paired renderer transmission and complete structural graph coverage without truncation. The nested member stayed in the bridge member list, but its bridge-member path was missing. With the return-only handler, the flat graph had api.write expression steps and the nested graph had no expression steps. With the clipboard handler, the nested graph retained the main handler's expression steps but still lacked its bridge-member path and wrapper join.

Case             Bridge members          Call-path entries          Wrapper joins
flat-return      write                   api.write                  0
nested-return    nested,nested.write      none                       0
flat-terminal    write                   api.write,nested:write     1
nested-terminal  nested,nested.write      nested:write               0

The outgoing exact api.write trace matched one seed and returned three expression nodes for the return-only handler, or six expression nodes and one wrapper join for the terminal handler. The corresponding api.nested.write trace matched zero seeds in both nested cases. The unqualified nested.write seed matched one context-bridge-api node and no outgoing edges. An unqualified write seed likewise found only the bridge node in the flat control, so the qualified seed comparison isolates the missing member path.

Evidence

  • Expected source: docs/javascript-artifact-reconstruction.md:229 documents bounded literal bridge member paths; :307 describes recovered Electron candidate paths; :319 describes exact API-seed tracing through a unique IPC pairing to an external API call. The nested literal member enumeration is implemented in src/domain/javascript/electronStaticAnalysisValues.ts:137.
  • Failure source: The inline four-case test independently observed the graph and authenticated feature-trace outputs above. Relevant source locators are src/domain/javascript/electronWrappedCallPaths.ts:363 and :408, src/domain/javascript/javascriptSymbolicBindings.ts:289, and src/application/javascript/ElectronBoundaryGraphCallPaths.ts:35 and :135.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The fixtures have stable destructured Electron bindings, literal keys and channels, one nested object, no unknown spreads or accessors, and no conditional exposure or mutation. Verification used real fixture directories and production application analysis and authenticated feature-trace boundaries without mocks. The compiled CLI and MCP transport were not exercised.

Restoration check

Analyze the same nested preload with both handlers. The nested graph must retain a candidate bridge-member path for api.nested.write, and its outgoing exact API trace must match and include the ipcRenderer.invoke terminal. With the clipboard handler it must also include the uniquely paired handler path and a wrapper join. Keep the flat api.write controls passing. A return-only handler need not acquire a path to an external terminal. Preserve the nested member list, IPC pairing, Evidence attribution and conservative handling of uncertain inputs. Counting unrelated handler nodes or matching only the bridge exposure does not restore the member path.

Runtime or environment

Source revision: a2d8b94dd5. Source tests used Node.js 24.18.0 and npm 11.16.0. The final focused run passed one diagnostic test containing four cases. The published rea-agents 6.4.0 package was not tested. Targets were generated CommonJS application directories, with no Electron process started.

Current upstream recheck at 3ae826ceba found the relevant nested-member producer, graph projection and API trace consumer unchanged, establishing continued presence from source. Two focused rerun attempts stopped during the flat control because dist/composition/javascriptAnalysisWorkerProcess.js was absent; no new application-graph or feature-trace result was obtained. No build, compiled CLI, MCP transport or published-package execution ran during this recheck.

Additional context

The closed Electron alias-recovery report concerns bindings initialized from other Electron bindings. This case uses direct destructuring and a nested object literal, so restoring alias recovery does not establish nested-member path recovery.

Related: #2560 concerns returned API objects inside control-flow statements, and #2174 concerns bundled isolated-world exposure. This case has an unguarded main-world nested literal object whose exposure and IPC pairing are already recovered.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A bounded static reconstruction loses a documented member's candidate call path and exact API trace while retaining the exposure inventory and IPC pairing. This makes affected application evidence incomplete; no broader application failure was observed.
  • Workaround status: unverified
  • Workaround basis: Neither qualified nor unqualified nested-member API seeds recovered its call path in the observed result. Flattening the analyzed source changes the artifact. Other query compositions were not exhaustively tested for an equivalent member-specific result.
Originally created by @coygeek on GitHub (Oct 13, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2669 ## Summary An application can expose a nested function through contextBridge and have its IPC channel recovered and paired, yet lose the candidate call path for that member. The graph lists nested.write as an exposed member, but an exact api.nested.write feature trace returns no matches. A flat member using the same IPC call traces successfully. ## Steps to reproduce Use the source checkout and contributor toolchain listed under Runtime or environment, with its locked dependencies and compiled JavaScript analysis worker available. Save the following diagnostic test as src/application/javascript/bridgeMemberTrace.test.ts and run it from the repository root. It invokes the production application analysis and authenticated feature-trace boundaries on disposable two-file directories. The application files remain inert data. The four cases compare flat and nested exposure with a return-only handler and with a handler whose external terminal is statically recognizable. ```sh npm run test:focused -- src/application/javascript/bridgeMemberTrace.test.ts ``` ```ts import { expect, it } from "vitest"; import { mkdir, writeFile, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; import { traceApplicationFeatureEvidence } from "./JavaScriptApplicationWorkflowService.js"; import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; it("compares nested and flat bridge closures without executing the fixture", async () => { const root = await mkdtemp(join(tmpdir(), "bridge-members-")); try { for (const terminal of [false, true]) { for (const nested of [false, true]) { const mode = `${nested ? "nested" : "flat"}-${terminal ? "terminal" : "return"}`; const dir = join(root, mode); await mkdir(dir, { recursive: true }); const member = 'write: (value) => ipcRenderer.invoke("nested:write", value)'; const preload = `const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("api", { ${nested ? `nested: { ${member} }` : member} });`; const main = terminal ? 'const { ipcMain, clipboard } = require("electron"); ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));' : 'const { ipcMain } = require("electron"); ipcMain.handle("nested:write", (event, value) => value);'; await writeFile(join(dir, "preload.js"), preload); await writeFile(join(dir, "main.js"), main); const facts = analyzeJavaScriptStaticSource(preload); console.log("static", JSON.stringify({ mode, status: facts.parse_status, members: facts.electron.context_bridge_apis?.map(x => x.members), paths: facts.electron.call_paths?.map(x => ({entry:x.entry,terminal:x.terminal,channel:x.channel})) })); const resources = new ArtifactResourceScope(); try { const result = await analyzeJavaScriptApplication({input_path: dir, format: "directory", max_heap_mb: 128, analysis_timeout_ms: 10000}, resources); expect(result.ok).toBe(true); if (!result.ok) throw result.error; const application = result.value.normalized_result as any; const graph = application.graph; const expressionNodes = graph.nodes.filter((n: any) => n.kind === "javascript-expression"); const entries = [...new Set(expressionNodes.flatMap((n: any) => n.observations.map((o: any) => o.properties.entry)))]; const joins = graph.edges.filter((e: any) => e.evidence.extractor.operation === "join-electron-wrapper-paths"); const seeds = nested ? ["api.nested.write", "nested.write"] : ["api.write", "write"]; const traces = seeds.map(value => { const trace = traceApplicationFeatureEvidence({application: result.value, seed: {kind:"api",value,match:"exact",case_sensitive:true},direction:"outgoing"}); expect(trace.ok).toBe(true); if (!trace.ok) throw trace.error; const t = trace.value.normalized_result as any; return {seed:value,matches:t.summary.matched_seeds,nodes:t.graph?.nodes.map((n:any)=>n.kind),joins:t.graph?.edges.filter((e:any)=>e.evidence.extractor.operation==="join-electron-wrapper-paths").length}; }); console.log("application", JSON.stringify({mode,members:graph.nodes.filter((n:any)=>n.kind==="context-bridge-api").flatMap((n:any)=>n.observations.map((o:any)=>o.properties.members)),entries,joins:joins.length,summary:application.summary,coverage:graph.coverage,traces})); expect(entries.includes(nested ? "api.nested.write" : "api.write")).toBe(!nested); expect(joins.length).toBe(!nested && terminal ? 1 : 0); } finally { await resources.close(); } } } } finally { await rm(root, { recursive: true, force: true }); } }, 45000); ``` The diagnostic assertions record the current failing behavior, so the test passes while that behavior remains present. Delete the diagnostic test afterward. The reviewed run used the repository's focused source-test launcher; the outer npm command shown above is the documented equivalent invocation and was not separately executed. ## Expected behavior The nested member should retain a candidate path beginning at api.nested.write and ending at its ipcRenderer.invoke call, just as the flat member retains api.write. With the uniquely paired clipboard handler, an outgoing exact API trace should include the member path and the handler's candidate path through their wrapper join. The paths must retain their static candidate status; static reconstruction does not prove application execution. ## Actual behavior All four application analyses succeeded and reported one paired renderer transmission and complete structural graph coverage without truncation. The nested member stayed in the bridge member list, but its bridge-member path was missing. With the return-only handler, the flat graph had api.write expression steps and the nested graph had no expression steps. With the clipboard handler, the nested graph retained the main handler's expression steps but still lacked its bridge-member path and wrapper join. ```text Case Bridge members Call-path entries Wrapper joins flat-return write api.write 0 nested-return nested,nested.write none 0 flat-terminal write api.write,nested:write 1 nested-terminal nested,nested.write nested:write 0 ``` The outgoing exact api.write trace matched one seed and returned three expression nodes for the return-only handler, or six expression nodes and one wrapper join for the terminal handler. The corresponding api.nested.write trace matched zero seeds in both nested cases. The unqualified nested.write seed matched one context-bridge-api node and no outgoing edges. An unqualified write seed likewise found only the bridge node in the flat control, so the qualified seed comparison isolates the missing member path. ## Evidence - Expected source: docs/javascript-artifact-reconstruction.md:229 documents bounded literal bridge member paths; :307 describes recovered Electron candidate paths; :319 describes exact API-seed tracing through a unique IPC pairing to an external API call. The nested literal member enumeration is implemented in src/domain/javascript/electronStaticAnalysisValues.ts:137. - Failure source: The inline four-case test independently observed the graph and authenticated feature-trace outputs above. Relevant source locators are src/domain/javascript/electronWrappedCallPaths.ts:363 and :408, src/domain/javascript/javascriptSymbolicBindings.ts:289, and src/application/javascript/ElectronBoundaryGraphCallPaths.ts:35 and :135. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete The fixtures have stable destructured Electron bindings, literal keys and channels, one nested object, no unknown spreads or accessors, and no conditional exposure or mutation. Verification used real fixture directories and production application analysis and authenticated feature-trace boundaries without mocks. The compiled CLI and MCP transport were not exercised. ## Restoration check Analyze the same nested preload with both handlers. The nested graph must retain a candidate bridge-member path for api.nested.write, and its outgoing exact API trace must match and include the ipcRenderer.invoke terminal. With the clipboard handler it must also include the uniquely paired handler path and a wrapper join. Keep the flat api.write controls passing. A return-only handler need not acquire a path to an external terminal. Preserve the nested member list, IPC pairing, Evidence attribution and conservative handling of uncertain inputs. Counting unrelated handler nodes or matching only the bridge exposure does not restore the member path. ## Runtime or environment Source revision: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4. Source tests used Node.js 24.18.0 and npm 11.16.0. The final focused run passed one diagnostic test containing four cases. The published rea-agents 6.4.0 package was not tested. Targets were generated CommonJS application directories, with no Electron process started. Current upstream recheck at 3ae826ceba6f25b08bce50bcb51001209bc16b57 found the relevant nested-member producer, graph projection and API trace consumer unchanged, establishing continued presence from source. Two focused rerun attempts stopped during the flat control because dist/composition/javascriptAnalysisWorkerProcess.js was absent; no new application-graph or feature-trace result was obtained. No build, compiled CLI, MCP transport or published-package execution ran during this recheck. ## Additional context The closed [Electron alias-recovery report](https://github.com/morluto/rea/issues/1656) concerns bindings initialized from other Electron bindings. This case uses direct destructuring and a nested object literal, so restoring alias recovery does not establish nested-member path recovery. Related: #2560 concerns returned API objects inside control-flow statements, and #2174 concerns bundled isolated-world exposure. This case has an unguarded main-world nested literal object whose exposure and IPC pairing are already recovered. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A bounded static reconstruction loses a documented member's candidate call path and exact API trace while retaining the exposure inventory and IPC pairing. This makes affected application evidence incomplete; no broader application failure was observed. - Workaround status: unverified - Workaround basis: Neither qualified nor unqualified nested-member API seeds recovered its call path in the observed result. Flattening the analyzed source changes the artifact. Other query compositions were not exhaustively tested for an equivalent member-specific result.
bsakaguchi 2026-10-15 18:30:00 +00:00
  • closed this issue
  • added the
    bug
    label
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#970
No description provided.