ADR-010: IPC capture/invoke/stub plugin via main-process instrumentation
Status: Accepted (capture + invoke + stub; multi-session; send/on + main→renderer capture opt-ins)
Context
An agent driving an Electron app through Stagewright sees the DOM (snapshot, find, read, interact)
but not the renderer↔main IPC traffic underneath it. "Did clicking Save fire the save-file IPC
with the right payload?" is unanswerable from the DOM alone. IPC capture/invoke/stub is the second
differentiation plugin (after trace), and the first to need to run code in the MAIN process rather
than a renderer.
Two questions had to be settled before building it: HOW a plugin reaches ipcMain (it is not a DOM
surface), and what SECURITY posture gates that reach.
Decision
1. The plugin instruments the main process through the transport's eval seam
@electron-stagewright/plugin-ipc drives the main process via the session transport's
evaluate('main', body, arg) — the same method the core electron_eval_main tool uses. A single
self-contained body (INSTRUMENT_BODY, no imports / no closures over module scope — the snapshot
walker's constraint) dispatches on arg.op over a persistent globalThis.__swIpc state:
- install — wraps
ipcMain.handleso a call to an allowlisted channel is recorded ({ channel, type, args, ok, ms, ts }); re-wraps already-registered handlers best-effort via the internal_invokeHandlersmap. WithcaptureSendit also wrapsipcMain.onfor fire-and-forget capture — both future registrations and listeners already registered for an allowlisted channel (plainonlisteners only; pre-existingoncelisteners are left intact to keep their one-shot semantics). WithcaptureSendToRendererit wrapswebContents.send/sendToFrameon the sharedWebContentsprototype so main→renderer pushes on allowlisted channels are recorded too (reached from a live webContents; skipped if no window is open at install). - read / stop — return the buffered events; on stop, restore every wrapped handler, detach every
wrapped
onlistener and restore the app's original, and restore the patched methods — leaving no recording residue. - invoke — call a registered handle channel from main (driving the renderer's request), bounded by an optional timeout.
- stub — make an allowlisted channel's handler return a canned value for the capture's duration.
The plugin keeps the orchestration (allowlist enforcement, per-session capture state, redaction, error envelopes) in TypeScript and the main-process mutation in the body string.
2. Gated by the main eval opt-in AND explicit channel allowlists
Main-process eval is powerful, so the instrumentation tools are gated by the main eval opt-in and channel allowlists:
--allow-eval=main(or bare--allow-eval) — without a policy that permits main eval, every IPC tool returnsipc.EVAL_REQUIRED. The transport'sevaluatedoes NOT itself pass through the eval tool registration gate (that gate hides theelectron_eval_*tools, not the transport method), so the plugin enforces the main eval gate at the tool boundary viactx.allowEval.- An explicit channel allowlist —
ipc_capture_startrequires at least one channel; only allowlisted channels are wrapped, captured, stubbed, or recorded. There is no capture-everything. - An optional invoke allowlist —
ipc_invokeis unrestricted by default (it names its channel per call and is no more powerful thanelectron_eval_main, which main eval already permits), but an operator MAY bound it via theinvokeAllowconfig for defense-in-depth: when set,ipc_invokerefuses any channel outside it (ipc.CHANNEL_NOT_ALLOWED). It is independent of the capture/stub allowlist;undefined(omitted) is unrestricted and[]blocks all invoke.
This is the trust model of the eval tools (a first-party, in-process plugin the operator chose to
load) plus per-channel boundaries. The redact config drops named arg fields before captured
payloads reach the agent.
Rationale
- Reusing
evaluate('main')avoids a new transport contract method for IPC — the seam already exists, is capability-flagged (supportsMainEval), and is the honest place this power lives. - Wrapping
ipcMain.handleat install + re-wrapping the internal map covers both handlers registered after capture starts and those registered at app startup (the gated smoke proves the latter). - The main eval opt-in plus explicit allowlists keep the blast radius explicit and operator-controlled.
Alternatives considered
- A dedicated transport IPC API (e.g.
transport.ipcCapture(...)) — heavier contract surface across every transport for a capability only the Playwright transport can serve today; the eval seam already expresses it. - A separate
--allow-ipcflag instead of reusing the main eval opt-in — more flags for the same underlying capability (arbitrary main-process JS). Folding it under the existing eval policy keeps one model for "this server may run app-process code." - Capturing all channels — rejected; an allowlist is the security boundary the scope demands.
Consequences
- New package
@electron-stagewright/plugin-ipcwithipc_capture_start/ipc_captured/ipc_capture_stop/ipc_invoke/ipc_stuband namespacedipc.*error codes. - A plugin reaching
transport.evaluate('main')bypasses the eval tool registration gate, so any such plugin MUST re-assert the main eval gate at the tool boundary (this plugin checksctx.allowEval). Captured payloads may contain sensitive data;redactmitigates, same as the trace plugin. - Re-wrapping existing handlers depends on Electron's internal
_invokeHandlersmap — guarded, with a documented limitation if a future Electron changes it. - One capture per session: concurrent app sessions capture independently, keyed by the unique
session id. Every op resolves its session first, then looks up that session's capture — so a
read/stop/stub cannot bleed across sessions, and the single-active-capture guard the first cut
needed is gone. The capture registry and config are module-level, so co-resident servers in the
same Node process still share plugin lifecycle/config; run fully independent server lifecycles in
separate processes.
send/oncapture is opt-in; on start it wraps both new and already-registeredonlisteners for allowlisted channels, and on stop it detaches them cleanly. The main→renderer direction (webContents.send/sendToFrame) is captured under thecaptureSendToRendereropt-in by wrapping the sharedWebContentsprototype (restored on stop). ipc_invokestays unrestricted by default (eval-equivalent), but is boundable per-deployment via theinvokeAllowconfig without a code change; capture and stub remain bound by the per-capture allowlist.
Related decisions
- ADR-004 (plugin model) — the contract + in-process trust model this plugin is built on.
- ADR-006 (error code registry) — the namespaced
ipc.*codes. - ADR-009 (dispatch seam) — the sibling first-party plugin (trace) and the eval-tool gating context.
References
packages/plugin-ipc/src/instrument.ts—INSTRUMENT_BODYshim + purefilterEvents/redactEvents.packages/plugin-ipc/src/index.ts— the tools, allowlist + eval gate, per-session capture state.packages/plugin-ipc/tests/— pure-helper unit, simulated-main e2e, gated real-Electron smoke.