From 0351328e54ce70ea3f2814482e76b132774aff48 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:17 +0800 Subject: [PATCH] fix(electron): retain nested bridge facts through wrapper resolution Merge direct same-call nested member facts into symbolic wrapper results using canonical deduplication and ordering. Preserve unknown spread coverage rather than losing known member paths. Validation: bounded source cases and public CLI/stdio MCP spread/no-spread controls. The existing 125,000-member stress case exceeded the 384 MiB local cap and was stopped without increasing the limit; that capacity case remains unverified locally. Fixes #3212 --- .../electronWrappedCallPaths.test.ts | 34 +++++++++++++++ .../javascript/electronWrappedCallPaths.ts | 8 +++- .../cli/electronWrappedCallPaths.test.ts | 41 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index b92aa990f6..80d4bc7c05 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -140,6 +140,40 @@ it("preserves an observed empty contextBridge API object", () => { expect(result.parse_status).toBe("complete"); }); +it.each([ + [ + "explicit nested paths after an unknown spread", + `{ ...extra, a: { b: 1 }, c: 2 }`, + ["a", "a.b", "c"], + 1, + "dynamic", + ], + [ + "a direct object literal", + `{ a: { b: 1 }, c: 2 }`, + ["a", "a.b", "c"], + 0, + "object-literal", + ], + ["a dynamic API argument", "getApi()", [], 1, "dynamic"], +] as const)( + "preserves directly observed nested bridge members for %s", + (_name, api, members, unknownMembers, apiStatus) => { + const result = analyzeJavaScriptStaticSource(` + const { contextBridge } = require("electron"); + contextBridge.exposeInMainWorld("api", ${api}); + `); + expect(result.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + api_key: "api", + api_status: apiStatus, + members, + unknown_members: unknownMembers, + }), + ); + }, +); + describe("wrapped contextBridge exposure methods", () => { it("retains the original expression for a dynamic isolated world id", () => { const result = analyzeJavaScriptStaticSource( diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 979ab14555..deb922ea69 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -36,6 +36,7 @@ import { symbolicDefiniteExit, symbolicLogicalRight, } from "./javascriptSymbolicControlFlow.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; const callLabel = (node: t.Node): string => t.isSequenceExpression(node) @@ -410,7 +411,12 @@ export const collectElectronWrappedCallPaths = ( api_key_expression: null, api_status: "dynamic" as const, resolution_basis: "wrapper-substitution" as const, - members, + // The direct syntax pass recursively retains literal dotted paths. The + // symbolic resolver enumerates only immediate object keys, so its + // replacement must not erase paths already established at this call. + members: [ + ...new Set([...(existing?.value.members ?? []), ...members]), + ].sort(compareUnicodeCodePoints), unknown_members: resolver.unknownMembers(object), module_key: null, location, diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 186abcbbe9..128b8188e9 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -308,6 +308,47 @@ cliTest( ); for (const surface of ["CLI", "stdio MCP"] as const) { + cliTest( + `retains explicit nested contextBridge members after an unknown spread through ${surface}`, + async ({ cli }) => { + const analyzeBridge = async (api: string) => { + const root = await createTestTempDirectory( + "rea-electron-spread-bridge-members-", + ); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge } = require("electron"); + const extra = getExtra(); + contextBridge.exposeInMainWorld("api", ${api});`, + }); + return analyzeJavaScriptDirectory(surface, cli, root); + }; + const withSpread = await analyzeBridge("{ ...extra, a: { b: 1 }, c: 2 }"); + const control = await analyzeBridge("{ a: { b: 1 }, c: 2 }"); + + const bridgeObservation = (application: typeof withSpread) => + application.graph.nodes + .filter((node) => node.kind === "context-bridge-api") + .flatMap((node) => node.observations) + .find((observation) => observation.properties.api_key === "api"); + expect(bridgeObservation(withSpread)).toMatchObject({ + properties: { + api_status: "dynamic", + members: ["a", "a.b", "c"], + unknown_members: 1, + }, + }); + expect(bridgeObservation(control)).toMatchObject({ + properties: { + api_status: "object-literal", + members: ["a", "a.b", "c"], + unknown_members: 0, + }, + }); + expect(withSpread.summary.exposed_api_members).toBe(3); + expect(control.summary.exposed_api_members).toBe(3); + }, + ); + cliTest( `preserves handler ambiguity and short-circuit factory paths through ${surface}`, async ({ cli }) => {