// src/backend-contract/ipc.ts import { contractError } from './errors' import type { CleanupRecord } from './errors' import { attachmentRecordsEqual, type AttachmentRecord } from './identity' import type { AttachmentId, ByteLimit, Capacity, ClientId, GenerationId, IpcOperationCorrelation, IpcVersionAxes, NegotiatedVersion, OpaqueId, OwnedBytes, SerializableRecord } from './primitives' import { byteLimit, opaqueId, ownBytes } from './primitives' import { snapshotSerializableRecord, utf8ByteLength } from './serializable' import type { BoundedAsyncStream } from './streams' /** Two overlapping bootstraps support React StrictMode handoff without permitting unbounded amplification. */ export const ELECTRON_MAX_ACTIVE_RENDERER_LEASES_PER_IDENTITY = 2 /** Framework-neutral name for the bounded overlapping client lease policy. */ export const IPC_MAX_ACTIVE_CLIENT_LEASES_PER_IDENTITY = ELECTRON_MAX_ACTIVE_RENDERER_LEASES_PER_IDENTITY /** Trusted-host permissions for security-sensitive desktop IPC operations. */ export type IpcSecurityPermission = | 'security:state' | 'security:pair' | 'security:cancel-pairing' | 'security:unpair' | 'security:custom-ceremony' export interface RendererIdentity { readonly clientId: ClientId readonly windowScope: string readonly sessionScope: string } /** Main-issued lifetime identity for one renderer bootstrap, independent of authenticated sender identity. */ export interface RendererLeaseIdentity { readonly leaseId: OpaqueId<'renderer-lease'> readonly generation: GenerationId<'renderer-lease-generation', string> } /** Main-process authentication facts derived from Electron's sender, never renderer input. */ export interface TrustedIpcSender { readonly authenticatedClientId: ClientId readonly authenticatedWindowScope: string readonly authenticatedSessionScope: string /** Derived by the main process; renderer payloads cannot grant or change it. */ readonly securityPermissions?: readonly IpcSecurityPermission[] } /** Framework-neutral aliases used by desktop webview transports such as Tauri. */ export type IpcClientIdentity = RendererIdentity export type IpcClientLeaseIdentity = RendererLeaseIdentity export type TrustedIpcCaller = TrustedIpcSender /** Fixed limits owned by the main process for one attached Electron backend. */ export interface IpcQuota { readonly maximumMessageBytes: ByteLimit readonly maximumOutstandingOperations: Capacity readonly maximumRetainedBytes: ByteLimit } /** * Untrusted renderer request. Attachment and versions are checked against the * main-process authority; renderer code cannot select quotas or other limits. */ export interface IpcEnvelope { readonly versions: IpcVersionAxes readonly attachment: AttachmentRecord readonly attachmentId: AttachmentId readonly renderer: RendererIdentity readonly rendererLease: RendererLeaseIdentity readonly correlation: IpcOperationCorrelation readonly dispatchEpoch: GenerationId<'ipc-dispatch-epoch', `${Attachment}:${Operation}`> readonly command: string readonly payload: SerializableRecord readonly binaryPayload: OwnedBytes | null readonly quota?: never } export interface RendererSnapshot { readonly attachment: AttachmentRecord readonly attachmentId: AttachmentId readonly renderer: RendererIdentity readonly rebindRequired: true readonly activeLeaseCount: number readonly activeLeases: readonly string[] readonly attachmentGeneration: GenerationId<'backend-generation', Attachment> readonly restorable: false } /** Immutable main-process configuration for exactly one attached backend generation. */ export interface ElectronMainArbiterAuthority { readonly attachment: AttachmentRecord readonly versions: IpcVersionAxes readonly quota: IpcQuota } /** Routing hooks receive requests only after all authority checks have completed. */ export interface ElectronMainArbiterHandlers { route( envelope: IpcEnvelope ): Promise release( identity: RendererIdentity, lease: RendererLeaseIdentity ): Promise } export interface ElectronMainArbiter { registerRenderer( identity: RendererIdentity, versions?: IpcVersionAxes ): RendererLeaseIdentity route( sender: TrustedIpcSender, envelope: IpcEnvelope ): Promise releaseRenderer( sender: TrustedIpcSender, lease: RendererLeaseIdentity ): Promise } interface RendererAccounting { readonly identity: RendererIdentity readonly lease: RendererLeaseIdentity readonly versions: IpcVersionAxes readonly securityPermissions: readonly IpcSecurityPermission[] /** * Bounded terminal replay ledger. In-flight entries are never evicted, while * settled entries remain long enough to reject a replay before LRU eviction. */ readonly replayLedger: Map lifecycle: 'active' | 'releasing' outstandingOperations: number releaseResult: Promise | null retainedBytes: number } interface ReplayLedgerEntry { readonly byteLength: number state: 'in-flight' | 'terminal' } interface PreparedEnvelope { readonly envelope: IpcEnvelope readonly totalBytes: number readonly replayKey: string readonly replayKeyBytes: number } /** * Main-process IPC authority. It owns the current full attachment tuple, * negotiated version axes, and hard quotas rather than trusting renderer data. */ export class IpcArbiterContext implements ElectronMainArbiter { /** * Safety bound on terminal notices retained for renderer replay. * * A trust-boundary quota, deliberately not renderer-configurable: it bounds * how much main-process memory a reloading renderer can pin while it re-reads * the terminal outcomes it missed. */ private static readonly maximumTerminalReplayEntries = 128 private readonly renderers = new Map>() private readonly authority: ElectronMainArbiterAuthority private readonly handlers: ElectronMainArbiterHandlers private nextRendererLease = 1 constructor(authority: ElectronMainArbiterAuthority, handlers: ElectronMainArbiterHandlers) { this.authority = snapshotArbiterAuthority(authority) this.handlers = handlers } registerRenderer( identity: RendererIdentity, versions: IpcVersionAxes = this.authority.versions, securityPermissions: readonly IpcSecurityPermission[] = [] ): RendererLeaseIdentity { let matchingLeaseCount = 0 for (const accounting of this.renderers.values()) { if (rendererIdentitiesEqual(accounting.identity, identity)) { matchingLeaseCount += 1 } } if (matchingLeaseCount >= ELECTRON_MAX_ACTIVE_RENDERER_LEASES_PER_IDENTITY) { throw contractError('stream.quota', 'ipc', 'electron-main-arbiter.renderer-leases') } const ordinal = this.nextRendererLease this.nextRendererLease += 1 const scope = `${String(this.authority.attachment.attachmentId)}:${String(identity.clientId)}` const lease = Object.freeze({ leaseId: opaqueId(`renderer-lease-${ordinal}`, 'renderer-lease', scope), generation: opaqueId(`renderer-lease-generation-${ordinal}`, 'renderer-lease-generation', scope) }) this.renderers.set(String(lease.leaseId), { identity: snapshotRendererIdentity(identity), lease, versions: snapshotIpcVersionAxes(versions), securityPermissions: snapshotSecurityPermissions(securityPermissions), replayLedger: new Map(), lifecycle: 'active', outstandingOperations: 0, releaseResult: null, retainedBytes: 0 }) return lease } async route( sender: TrustedIpcSender, envelope: IpcEnvelope ): Promise { assertRendererLeaseShape(envelope.rendererLease) this.assertRendererDoesNotSupplyAuthority(envelope) this.assertSender(sender, envelope.renderer) const accounting = this.requireAccounting(envelope.rendererLease) this.assertRegisteredRenderer(accounting, envelope.renderer, envelope.rendererLease) this.assertSecurityPermissions(accounting.securityPermissions, sender.securityPermissions) this.assertRendererActive(accounting) this.assertAttachment(envelope) this.assertVersions(accounting.versions, envelope) const prepared = this.prepareEnvelope(envelope, accounting.versions) assertSecurityCommandPermission( prepared.envelope.command, prepared.envelope.payload, accounting.securityPermissions ) // A cancellation must remain admissible when normal work has filled the // operation quota; it still receives full replay and byte accounting. const reservesOperationSlot = prepared.envelope.command !== 'operation.cancel' this.reserveOperation(accounting, prepared, reservesOperationSlot) try { return await this.handlers.route(prepared.envelope) } finally { if (reservesOperationSlot) { accounting.outstandingOperations -= 1 } accounting.retainedBytes -= prepared.totalBytes this.markReplayTerminal(accounting, prepared.replayKey) this.trimTerminalReplayLedger(accounting, 0, 0) } } async releaseRenderer( sender: TrustedIpcSender, lease: RendererLeaseIdentity ): Promise { assertRendererLeaseShape(lease) const accounting = this.renderers.get(String(lease.leaseId)) if (accounting === undefined) { return { state: 'released', failures: [] } } assertRendererLeaseMatches(accounting.lease, lease) this.assertSender(sender, accounting.identity) if (accounting.lifecycle === 'releasing') { if (accounting.releaseResult === null) { throw contractError('protocol.violation', 'ipc', 'electron-main-arbiter.release-accounting') } return accounting.releaseResult } accounting.lifecycle = 'releasing' const releaseResult = Promise.resolve() .then(() => this.handlers.release(accounting.identity, accounting.lease)) .then( cleanup => { if (cleanup.state === 'released') { this.renderers.delete(String(accounting.lease.leaseId)) } else { accounting.lifecycle = 'active' accounting.releaseResult = null } return cleanup }, error => { console.error('[ElectronMainArbiterContext.releaseRenderer] Renderer release rejected:', error) accounting.lifecycle = 'active' accounting.releaseResult = null throw error } ) accounting.releaseResult = releaseResult return releaseResult } private assertRendererDoesNotSupplyAuthority( envelope: IpcEnvelope ): void { if ('quota' in envelope) { throw contractError('protocol.violation', 'ipc', 'electron-main-arbiter.renderer-authority') } } private assertSender( sender: TrustedIpcSender, renderer: RendererIdentity ): void { if ( sender.authenticatedClientId !== renderer.clientId || sender.authenticatedWindowScope !== renderer.windowScope || sender.authenticatedSessionScope !== renderer.sessionScope ) { throw contractError('ownership.denied', 'ipc', 'electron-main-arbiter.sender') } } private assertSecurityPermissions( expected: readonly IpcSecurityPermission[], supplied: readonly IpcSecurityPermission[] | undefined ): void { const actual = supplied ?? [] if (!securityPermissionsEqual(expected, actual)) { throw contractError('ownership.denied', 'ipc', 'electron-main-arbiter.security-scope') } } private assertRegisteredRenderer( accounting: RendererAccounting, identity: RendererIdentity, lease: RendererLeaseIdentity ): void { if (!rendererIdentitiesEqual(accounting.identity, identity)) { throw contractError('ownership.denied', 'ipc', 'electron-main-arbiter.renderer-registration') } assertRendererLeaseMatches(accounting.lease, lease) } private assertRendererActive(accounting: RendererAccounting): void { if (accounting.lifecycle !== 'active') { throw contractError('lifecycle.invalid-state', 'ipc', 'electron-main-arbiter.renderer-releasing') } } private assertAttachment( envelope: IpcEnvelope ): void { if ( envelope.attachmentId !== envelope.attachment.attachmentId || envelope.attachmentId !== this.authority.attachment.attachmentId || !attachmentRecordsEqual(envelope.attachment, this.authority.attachment) ) { throw contractError('protocol.violation', 'ipc', 'electron-main-arbiter.attachment') } } private assertVersions( versions: IpcVersionAxes, envelope: IpcEnvelope ): void { if (!ipcVersionsEqual(envelope.versions, versions)) { throw contractError('protocol.incompatible', 'ipc', 'electron-main-arbiter.versions') } } private prepareEnvelope( envelope: IpcEnvelope, versions: IpcVersionAxes ): PreparedEnvelope { const payload = snapshotSerializableRecord(envelope.payload) const binaryPayload = envelope.binaryPayload === null ? null : ownBytes(envelope.binaryPayload, byteLimit(envelope.binaryPayload.byteLength)) const totalBytes = payload.byteLength + (binaryPayload?.byteLength ?? 0) + envelopeMetadataByteLength(envelope) if (totalBytes > this.authority.quota.maximumMessageBytes) { throw contractError('bytes.too-large', 'ipc', 'electron-main-arbiter.payload-size') } const replayKey = operationReplayKey(envelope) return { envelope: Object.freeze({ ...envelope, attachment: this.authority.attachment, versions, renderer: snapshotRendererIdentity(envelope.renderer), rendererLease: snapshotRendererLease(envelope.rendererLease), payload: payload.value, binaryPayload }), totalBytes, replayKey, replayKeyBytes: utf8ByteLength(replayKey) } } private requireAccounting(lease: RendererLeaseIdentity): RendererAccounting { const accounting = this.renderers.get(String(lease.leaseId)) if (accounting === undefined) { throw contractError('ownership.denied', 'ipc', 'electron-main-arbiter.renderer-registration') } return accounting } private reserveOperation( accounting: RendererAccounting, prepared: PreparedEnvelope, reservesOperationSlot: boolean ): void { if (accounting.replayLedger.has(prepared.replayKey)) { throw contractError('protocol.violation', 'ipc', 'electron-main-arbiter.replay') } if ( reservesOperationSlot && accounting.outstandingOperations >= this.authority.quota.maximumOutstandingOperations ) { throw contractError('stream.quota', 'ipc', 'electron-main-arbiter.outstanding-operations') } this.trimTerminalReplayLedger(accounting, prepared.totalBytes + prepared.replayKeyBytes, 1) const retainedAfterReservation = accounting.retainedBytes + prepared.totalBytes + prepared.replayKeyBytes if (retainedAfterReservation > this.authority.quota.maximumRetainedBytes) { throw contractError('stream.quota', 'ipc', 'electron-main-arbiter.retained-bytes') } accounting.replayLedger.set(prepared.replayKey, { byteLength: prepared.replayKeyBytes, state: 'in-flight' }) if (reservesOperationSlot) { accounting.outstandingOperations += 1 } accounting.retainedBytes = retainedAfterReservation } private markReplayTerminal(accounting: RendererAccounting, replayKey: string): void { const entry = accounting.replayLedger.get(replayKey) if (entry === undefined) { throw contractError('lifecycle.invariant-violation', 'ipc', 'electron-main-arbiter.replay-ledger') } entry.state = 'terminal' } /** * Retains settled replay keys only while their exact byte accounting fits the * configured renderer budget and the bounded LRU window. A Map's insertion * order gives us deterministic eviction without timers or delayed cleanup. */ private trimTerminalReplayLedger( accounting: RendererAccounting, incomingBytes: number, incomingEntryCount: number ): void { while ( accounting.replayLedger.size + incomingEntryCount > IpcArbiterContext.maximumTerminalReplayEntries || accounting.retainedBytes + incomingBytes > this.authority.quota.maximumRetainedBytes ) { const oldestTerminal = this.oldestTerminalReplayKey(accounting) if (oldestTerminal === null) { return } const entry = accounting.replayLedger.get(oldestTerminal) if (entry === undefined) { throw contractError('lifecycle.invariant-violation', 'ipc', 'electron-main-arbiter.replay-ledger') } accounting.replayLedger.delete(oldestTerminal) accounting.retainedBytes -= entry.byteLength } } private oldestTerminalReplayKey(accounting: RendererAccounting): string | null { for (const [key, entry] of accounting.replayLedger) { if (entry.state === 'terminal') { return key } } return null } } /** Framework-neutral authority aliases. Electron names remain source-compatible for 4.0 migration. */ export type IpcArbiterAuthority = ElectronMainArbiterAuthority export type IpcArbiterHandlers = ElectronMainArbiterHandlers export type IpcArbiter = ElectronMainArbiter export interface ElectronRendererBoundary { readonly identity: RendererIdentity readonly events: BoundedAsyncStream snapshot(): Promise> } function rendererIdentitiesEqual( left: RendererIdentity, right: RendererIdentity ): boolean { return ( left.clientId === right.clientId && left.windowScope === right.windowScope && left.sessionScope === right.sessionScope ) } function snapshotRendererIdentity( identity: RendererIdentity ): RendererIdentity { return Object.freeze({ clientId: identity.clientId, windowScope: identity.windowScope, sessionScope: identity.sessionScope }) } function operationReplayKey( envelope: IpcEnvelope ): string { return `${String(envelope.rendererLease.leaseId)}\u0000${String(envelope.rendererLease.generation)}\u0000${String( envelope.correlation )}\u0000${String(envelope.dispatchEpoch)}` } function envelopeMetadataByteLength( envelope: IpcEnvelope ): number { const strings = [ envelope.command, String(envelope.attachmentId), String(envelope.attachment.backendInstanceId), String(envelope.attachment.backendGeneration), String(envelope.attachment.adapter.adapterId), String(envelope.attachment.adapter.adapterGeneration), String(envelope.renderer.clientId), envelope.renderer.windowScope, envelope.renderer.sessionScope, String(envelope.rendererLease.leaseId), String(envelope.rendererLease.generation), String(envelope.correlation), String(envelope.dispatchEpoch) ] let byteLength = 0 for (const value of strings) { byteLength += utf8ByteLength(value) } return byteLength + ipcVersionsByteLength(envelope.versions) } function assertRendererLeaseMatches(actual: RendererLeaseIdentity, expected: RendererLeaseIdentity): void { if (actual.leaseId !== expected.leaseId || actual.generation !== expected.generation) { throw contractError('ownership.denied', 'ipc', 'electron-main-arbiter.renderer-lease') } } function assertRendererLeaseShape(lease: RendererLeaseIdentity): void { if ( typeof lease !== 'object' || lease === null || typeof lease.leaseId !== 'string' || lease.leaseId.length === 0 || typeof lease.generation !== 'string' || lease.generation.length === 0 ) { throw contractError('protocol.malformed', 'ipc', 'electron-main-arbiter.renderer-lease') } } function snapshotRendererLease(lease: RendererLeaseIdentity): RendererLeaseIdentity { return Object.freeze({ leaseId: lease.leaseId, generation: lease.generation }) } function snapshotSecurityPermissions(permissions: readonly IpcSecurityPermission[]): readonly IpcSecurityPermission[] { const unique = [...new Set(permissions)] if (unique.some(permission => !isIpcSecurityPermission(permission))) { throw contractError('protocol.violation', 'ipc', 'electron-main-arbiter.security-scope') } return Object.freeze(unique) } function securityPermissionsEqual( expected: readonly IpcSecurityPermission[], actual: readonly IpcSecurityPermission[] ): boolean { return expected.length === actual.length && expected.every(permission => actual.includes(permission)) } function isIpcSecurityPermission(value: string): value is IpcSecurityPermission { return ( value === 'security:state' || value === 'security:pair' || value === 'security:cancel-pairing' || value === 'security:unpair' || value === 'security:custom-ceremony' ) } function assertSecurityCommandPermission( command: string, payload: SerializableRecord, permissions: readonly IpcSecurityPermission[] ): void { const permission = command === 'security.state' ? 'security:state' : command === 'security.pair' ? 'security:pair' : command === 'security.cancel-pairing' ? 'security:cancel-pairing' : command === 'security.unpair' ? 'security:unpair' : command === 'security.custom-ceremony' ? 'security:custom-ceremony' : null const required: IpcSecurityPermission[] = permission === null ? [] : [permission] if (command === 'security.pair' && payload.ceremony !== undefined && payload.ceremony !== 'system') { required.push('security:custom-ceremony') } for (const requiredPermission of required) { if (!permissions.includes(requiredPermission)) { throw contractError('permission.denied', 'ipc', `electron-main-arbiter.${requiredPermission}`) } } } function ipcVersionsByteLength(versions: IpcVersionAxes): number { return ( negotiatedVersionByteLength(versions.backendContract) + negotiatedVersionByteLength(versions.capabilitySchema) + negotiatedVersionByteLength(versions.eventSchema) + negotiatedVersionByteLength(versions.traceFormat) + negotiatedVersionByteLength(versions.ipcProtocol) ) } function negotiatedVersionByteLength(version: NegotiatedVersion): number { return ( utf8ByteLength(version.axis) + utf8ByteLength(String(version.selected.value)) + utf8ByteLength(String(version.localRange.minimum.value)) + utf8ByteLength(String(version.localRange.maximum.value)) + utf8ByteLength(String(version.remoteRange.minimum.value)) + utf8ByteLength(String(version.remoteRange.maximum.value)) ) } function ipcVersionsEqual(left: IpcVersionAxes, right: IpcVersionAxes): boolean { return ( negotiatedVersionsEqual(left.backendContract, right.backendContract) && negotiatedVersionsEqual(left.capabilitySchema, right.capabilitySchema) && negotiatedVersionsEqual(left.eventSchema, right.eventSchema) && negotiatedVersionsEqual(left.traceFormat, right.traceFormat) && negotiatedVersionsEqual(left.ipcProtocol, right.ipcProtocol) ) } function negotiatedVersionsEqual( left: NegotiatedVersion, right: NegotiatedVersion ): boolean { return ( left.axis === right.axis && left.selected.axis === right.selected.axis && left.selected.value === right.selected.value && left.localRange.axis === right.localRange.axis && left.localRange.minimum.axis === right.localRange.minimum.axis && left.localRange.minimum.value === right.localRange.minimum.value && left.localRange.maximum.axis === right.localRange.maximum.axis && left.localRange.maximum.value === right.localRange.maximum.value && left.remoteRange.axis === right.remoteRange.axis && left.remoteRange.minimum.axis === right.remoteRange.minimum.axis && left.remoteRange.minimum.value === right.remoteRange.minimum.value && left.remoteRange.maximum.axis === right.remoteRange.maximum.axis && left.remoteRange.maximum.value === right.remoteRange.maximum.value ) } function snapshotArbiterAuthority( authority: ElectronMainArbiterAuthority ): ElectronMainArbiterAuthority { return Object.freeze({ attachment: snapshotAttachmentRecord(authority.attachment), versions: snapshotIpcVersionAxes(authority.versions), quota: Object.freeze({ maximumMessageBytes: authority.quota.maximumMessageBytes, maximumOutstandingOperations: authority.quota.maximumOutstandingOperations, maximumRetainedBytes: authority.quota.maximumRetainedBytes }) }) } function snapshotAttachmentRecord( attachment: AttachmentRecord ): AttachmentRecord { return Object.freeze({ attachmentId: attachment.attachmentId, backendInstanceId: attachment.backendInstanceId, backendGeneration: attachment.backendGeneration, adapter: Object.freeze({ adapterId: attachment.adapter.adapterId, displayName: attachment.adapter.displayName, state: Object.freeze({ availability: attachment.adapter.state.availability, authorization: attachment.adapter.state.authorization, power: attachment.adapter.state.power, backendGeneration: attachment.adapter.state.backendGeneration, updatedAt: attachment.adapter.state.updatedAt, safeReason: attachment.adapter.state.safeReason }), adapterGeneration: attachment.adapter.adapterGeneration, limitations: Object.freeze([...attachment.adapter.limitations]) }) }) } function snapshotIpcVersionAxes(versions: IpcVersionAxes): IpcVersionAxes { return Object.freeze({ backendContract: snapshotNegotiatedVersion(versions.backendContract), capabilitySchema: snapshotNegotiatedVersion(versions.capabilitySchema), eventSchema: snapshotNegotiatedVersion(versions.eventSchema), traceFormat: snapshotNegotiatedVersion(versions.traceFormat), ipcProtocol: snapshotNegotiatedVersion(versions.ipcProtocol) }) } function snapshotNegotiatedVersion(version: NegotiatedVersion): NegotiatedVersion { return Object.freeze({ axis: version.axis, selected: Object.freeze({ axis: version.selected.axis, value: version.selected.value }), localRange: Object.freeze({ axis: version.localRange.axis, minimum: Object.freeze({ axis: version.localRange.minimum.axis, value: version.localRange.minimum.value }), maximum: Object.freeze({ axis: version.localRange.maximum.axis, value: version.localRange.maximum.value }) }), remoteRange: Object.freeze({ axis: version.remoteRange.axis, minimum: Object.freeze({ axis: version.remoteRange.minimum.axis, value: version.remoteRange.minimum.value }), maximum: Object.freeze({ axis: version.remoteRange.maximum.axis, value: version.remoteRange.maximum.value }) }) }) }