From dcb01cd1c963e201ddc6c8f4366c89d8e2c3660a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:35:50 -0700 Subject: [PATCH 1/3] fix(ipc): strip Electron's invoke envelope at the preload boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron rejects a failed `ipcMain.handle` with "Error invoking remote method '': ", and the renderer's ordinary idiom is to render `err.message`. Six PRs have now fixed that one call site at a time. Enumerating by render sink rather than by stripper shows why that was not converging: 233 renderer expressions read a rejection as free text and pass it to a toast or a state setter, 128 of them can carry an envelope, and 115 did not strip it. There is no per-site rule available, because the leaking shape *is* the correct shape everywhere else — the discriminator is whether the value crossed IPC, which is invisible where it is rendered. So strip it where it is created. All 731 `ipcRenderer.invoke(` calls (702 in `index.ts` plus one written across two lines, 28 in `gitlab.ts`) now go through `ipc-invoke-boundary.ts`, which rejects with the reason alone. It reuses the canonical stripper from #17237 rather than adding a seventh regex. Nothing is lost to diagnostics: the same error object is rethrown, so identity, properties and stack survive, and the wrapped form is logged against the channel that produced it. An envelope with no readable reason behind it is left exactly as it was — narrowing that to an empty string renders an empty toast, which is worse than the plumbing it replaces, and the call sites that must never show plumbing already branch on it through `extractIpcErrorMessage`. `ipc-invoke-boundary-ratchet.test.ts` keeps the 732nd call from being written outside the boundary, and closes the raw `window.electron.ipcRenderer` door too. --- .../ipc/github-ipc-channel-parity.test.ts | 3 +- src/preload/gitlab.ts | 64 +- src/preload/index.ts | 1530 ++++++++--------- .../ipc-invoke-boundary-ratchet.test.ts | 91 + src/preload/ipc-invoke-boundary.test.ts | 142 ++ src/preload/ipc-invoke-boundary.ts | 53 + src/preload/preload-surface-envelope.test.ts | 136 ++ .../components/terminal-pane/TerminalPane.tsx | 5 +- .../lib/ipc-error-call-site-census.test.ts | 23 +- 9 files changed, 1189 insertions(+), 858 deletions(-) create mode 100644 src/preload/ipc-invoke-boundary-ratchet.test.ts create mode 100644 src/preload/ipc-invoke-boundary.test.ts create mode 100644 src/preload/ipc-invoke-boundary.ts create mode 100644 src/preload/preload-surface-envelope.test.ts diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 928e0df9fbf..2a5a5df3d54 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -86,7 +86,8 @@ describe('GitHub IPC channel parity', () => { github.registerGitHubHandlers(harness.store as never, harness.stats as never) const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') - const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( + // Channels go through the preload boundary wrapper, so match the call rather than the receiver. + const exposedChannels = [...preloadSource.matchAll(/(? match[1] ) const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index d6f12367db0..2438b66e14a 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -2,7 +2,7 @@ adding or changing a `gl.*` channel doesn't surface as a merge conflict on every upstream sync of the much larger central preload file. Composed back into `api.gl` from `index.ts`. */ -import { ipcRenderer } from 'electron' +import { invoke } from './ipc-invoke-boundary' import type { TaskSourceContext } from '../shared/task-source-context' type GitLabRepoSelectorArgs = { @@ -12,23 +12,23 @@ type GitLabRepoSelectorArgs = { } export const glApi = { - viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), + viewer: (): Promise => invoke('gitlab:viewer'), + diagnoseAuth: (): Promise => invoke('gitlab:diagnoseAuth'), rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => - ipcRenderer.invoke('gitlab:rateLimit', args), + invoke('gitlab:rateLimit', args), projectSlug: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:projectSlug', args), + invoke('gitlab:projectSlug', args), mrForBranch: ( args: GitLabRepoSelectorArgs & { branch: string linkedMRIid?: number | null } - ): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + ): Promise => invoke('gitlab:mrForBranch', args), mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise => - ipcRenderer.invoke('gitlab:mr', args), + invoke('gitlab:mr', args), listMRs: ( args: GitLabRepoSelectorArgs & { @@ -37,7 +37,7 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + ): Promise => invoke('gitlab:listMRs', args), listWorkItems: ( args: GitLabRepoSelectorArgs & { @@ -46,10 +46,10 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + ): Promise => invoke('gitlab:listWorkItems', args), issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise => - ipcRenderer.invoke('gitlab:issue', args), + invoke('gitlab:issue', args), listIssues: ( args: GitLabRepoSelectorArgs & { @@ -59,7 +59,7 @@ export const glApi = { page?: number } ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => - ipcRenderer.invoke('gitlab:listIssues', args), + invoke('gitlab:listIssues', args), createIssue: ( args: GitLabRepoSelectorArgs & { @@ -67,65 +67,59 @@ export const glApi = { body: string } ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:createIssue', args), + invoke('gitlab:createIssue', args), updateIssue: ( args: GitLabRepoSelectorArgs & { number: number updates: unknown } - ): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:updateIssue', args), + ): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gitlab:updateIssue', args), addIssueComment: ( args: GitLabRepoSelectorArgs & { number: number; body: string } - ): Promise => ipcRenderer.invoke('gitlab:addIssueComment', args), + ): Promise => invoke('gitlab:addIssueComment', args), listLabels: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:listLabels', args), + invoke('gitlab:listLabels', args), listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:listAssignableUsers', args), + invoke('gitlab:listAssignableUsers', args), - todos: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:todos', args), + todos: (args: GitLabRepoSelectorArgs): Promise => invoke('gitlab:todos', args), workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + ): Promise => invoke('gitlab:workItemDetails', args), closeMR: ( args: GitLabRepoSelectorArgs & { iid: number } - ): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:closeMR', args), + ): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gitlab:closeMR', args), reopenMR: ( args: GitLabRepoSelectorArgs & { iid: number } - ): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:reopenMR', args), + ): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gitlab:reopenMR', args), mergeMR: ( args: GitLabRepoSelectorArgs & { iid: number method?: 'merge' | 'squash' | 'rebase' } - ): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:mergeMR', args), + ): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gitlab:mergeMR', args), updateMR: ( args: GitLabRepoSelectorArgs & { iid: number updates: unknown } - ): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gitlab:updateMR', args), + ): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gitlab:updateMR', args), updateMRReviewers: ( args: GitLabRepoSelectorArgs & { @@ -133,10 +127,10 @@ export const glApi = { reviewerIds: number[] projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + ): Promise => invoke('gitlab:updateMRReviewers', args), addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise => - ipcRenderer.invoke('gitlab:addMRComment', args), + invoke('gitlab:addMRComment', args), addMRInlineComment: ( args: GitLabRepoSelectorArgs & { @@ -144,7 +138,7 @@ export const glApi = { input: unknown projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + ): Promise => invoke('gitlab:addMRInlineComment', args), resolveMRDiscussion: ( args: GitLabRepoSelectorArgs & { @@ -152,15 +146,15 @@ export const glApi = { discussionId: string resolved: boolean } - ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + ): Promise => invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } - ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), + ): Promise => invoke('gitlab:jobTrace', args), retryJob: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:retryJob', args), + ): Promise => invoke('gitlab:retryJob', args), workItemByPath: ( args: GitLabRepoSelectorArgs & { @@ -169,5 +163,5 @@ export const glApi = { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) + ): Promise => invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index c13e7a30a7d..9035f0fef4a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: preload is the audited renderer/Electron IPC contract; co-locating the surface eases security and type-drift review. */ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import { invoke } from './ipc-invoke-boundary' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission' @@ -211,7 +212,10 @@ import type { RateLimitState } from '../shared/rate-limit-types' import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' -import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../shared/workspace-cleanup' import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github/auth-types' import type { TaskSourceContext } from '../shared/task-source-context' @@ -368,7 +372,7 @@ import { // Why: the sync checkpoint only stages; this joins its durable write so a // navigating path can abort instead of losing the staged session. async function awaitBeforeUnloadCheckpoint(): Promise { - const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + const result = (await invoke('app:await-before-unload-checkpoint')) as { ok?: unknown } if (result?.ok !== true) { @@ -562,27 +566,14 @@ ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { // Custom APIs for renderer const api = { app: { - getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), - getFeatureWallAssetBaseUrl: (): Promise => - ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), + getIdentity: (): Promise => invoke('app:getIdentity'), + getFeatureWallAssetBaseUrl: (): Promise => invoke('app:getFeatureWallAssetBaseUrl'), relaunch: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:relaunch'), - awaitBeforeUnloadCheckpoint - ), + prepareAndInvokeAppRestart(window, () => invoke('app:relaunch'), awaitBeforeUnloadCheckpoint), restart: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:restart'), - awaitBeforeUnloadCheckpoint - ), + prepareAndInvokeAppRestart(window, () => invoke('app:restart'), awaitBeforeUnloadCheckpoint), reload: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:reload'), - awaitBeforeUnloadCheckpoint - ), + prepareAndInvokeAppRestart(window, () => invoke('app:reload'), awaitBeforeUnloadCheckpoint), stageBeforeUnloadSync: (args: Parameters[0]) => { const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown @@ -592,21 +583,20 @@ const api = { } }, awaitFirstWindowStartupServices: (): Promise => - ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => - ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => - ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => startupDiagnosticsEnabled - ? ipcRenderer.invoke('app:startupDiagnostic', event, details) + ? invoke('app:startupDiagnostic', event, details) : Promise.resolve(), // Why: macOS input mode (or layout ID) so keyboard workarounds can tell CJK/compose layouts from US QWERTY (issue #1205); null on non-Darwin or read failure. - getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId'), + getKeyboardInputSourceId: (): Promise => invoke('app:getKeyboardInputSourceId'), getMacCapturedDigitRowChords: (): Promise => - ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), - getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), + invoke('app:getMacCapturedDigitRowChords'), + getKeyboardLayoutSnapshot: () => invoke('app:getKeyboardLayoutSnapshot'), onKeyboardLayoutChanged: ( callback: (event: KeyboardLayoutChangeEvent) => void ): (() => void) => { @@ -618,36 +608,35 @@ const api = { return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) }, setUnreadDockBadgeCount: (count: number): Promise => - ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), + invoke('app:setUnreadDockBadgeCount', count), getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => - ipcRenderer.invoke('app:getFloatingTerminalCwd', args), - getFloatingMarkdownDirectory: (): Promise => - ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), + invoke('app:getFloatingTerminalCwd', args), + getFloatingMarkdownDirectory: (): Promise => invoke('app:getFloatingMarkdownDirectory'), pickFloatingMarkdownDocument: (): Promise => - ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), + invoke('app:pickFloatingMarkdownDocument'), pickFloatingWorkspaceDirectory: (): Promise => - ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), + invoke('app:pickFloatingWorkspaceDirectory'), writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => - ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) + invoke('terminal:writeRenderDesyncEvidence', args) }, orcaProfiles: { - list: () => ipcRenderer.invoke('orcaProfiles:list'), - authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), - createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), - createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), - findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), - connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), - refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), - signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), - selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), - orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), - orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), - orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), - orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), - orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) + list: () => invoke('orcaProfiles:list'), + authStatus: () => invoke('orcaProfiles:authStatus'), + createLocal: (args) => invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => invoke('orcaProfiles:switch', args), + transferProject: (args) => invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => invoke('orcaProfiles:orgMemberRemove', args) } satisfies PreloadApi['orcaProfiles'], platform: { @@ -667,59 +656,55 @@ const api = { } satisfies PreloadApi['platform'], wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), - listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') + isAvailable: (): Promise => invoke('wsl:isAvailable'), + listDistros: (): Promise => invoke('wsl:listDistros') }, pwsh: { - isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') + isAvailable: (): Promise => invoke('pwsh:isAvailable') }, gitBash: { - isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') + isAvailable: (): Promise => invoke('gitBash:isAvailable') }, plugins: { - list: (): Promise => ipcRenderer.invoke('plugins:list'), - listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + list: (): Promise => invoke('plugins:list'), + listLanguagePacks: () => invoke('plugins:listLanguagePacks'), consent: (args: PluginConsentRequest): Promise => - ipcRenderer.invoke('plugins:consent', args), + invoke('plugins:consent', args), setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => - ipcRenderer.invoke('plugins:setEnabled', args), + invoke('plugins:setEnabled', args), readPanelEntry: (args: { pluginKey: string panelId: string - }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + }): Promise => invoke('plugins:readPanelEntry', args), invokeCommand: (args: { pluginKey: string commandId: string args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + }): Promise => invoke('plugins:invokeCommand', args), panelAction: (args: { sessionToken: string action: string params?: unknown - }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + }): Promise => invoke('plugins:panelAction', args), install: (source: PluginHostInstallSource): Promise => - ipcRenderer.invoke('plugins:install', source), - listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), - addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), - removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), - refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), - listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), - previewMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), - installMarketplacePlugin: (preview) => - ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), - previewMarketplaceUpdate: (args) => - ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), - rollbackMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + invoke('plugins:install', source), + listMarketplaces: () => invoke('plugins:listMarketplaces'), + addMarketplace: (source) => invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => invoke('plugins:rollbackMarketplacePlugin', args), remove: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:remove', args), + invoke('plugins:remove', args), getLogs: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:getLogs', args), - refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + invoke('plugins:getLogs', args), + refresh: (): Promise => invoke('plugins:refresh'), onChanged: (callback): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => callback(change) @@ -731,45 +716,45 @@ const api = { } satisfies PreloadApi['plugins'], repos: { - list: () => ipcRenderer.invoke('repos:list'), + list: () => invoke('repos:list'), listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), + invoke('repos:listForExecutionHost', args), - add: (args) => ipcRenderer.invoke('repos:add', args), + add: (args) => invoke('repos:add', args), - addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), + addRemote: (args) => invoke('repos:addRemote', args), - create: (args) => ipcRenderer.invoke('repos:create', args), + create: (args) => invoke('repos:create', args), - isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), + isGitAvailable: (): Promise => invoke('repos:isGitAvailable'), getDefaultCreateProjectParent: (): Promise => - ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), + invoke('repos:getDefaultCreateProjectParent'), - remove: (args) => ipcRenderer.invoke('repos:remove', args), + remove: (args) => invoke('repos:remove', args), - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + removeForHost: (args) => invoke('repos:removeForHost', args), - reorder: (args) => ipcRenderer.invoke('repos:reorder', args), + reorder: (args) => invoke('repos:reorder', args), - reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), + reorderForHost: (args) => invoke('repos:reorderForHost', args), - update: (args) => ipcRenderer.invoke('repos:update', args), + update: (args) => invoke('repos:update', args), - pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), + pickFolder: () => invoke('repos:pickFolder'), - pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), + pickFolders: () => invoke('repos:pickFolders'), - pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), + pickDirectory: () => invoke('repos:pickDirectory'), - clone: (args) => ipcRenderer.invoke('repos:clone', args), + clone: (args) => invoke('repos:clone', args), - cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + cloneRemote: (args) => invoke('repos:cloneRemote', args), - createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + createRemote: (args) => invoke('repos:createRemote', args), - cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), + cloneAbort: () => invoke('repos:cloneAbort'), onCloneProgress: ( callback: (data: { phase: string; percent: number }) => void @@ -783,26 +768,26 @@ const api = { }, getGitUsername: (args: { repoId: string }): Promise => - ipcRenderer.invoke('repos:getGitUsername', args), + invoke('repos:getGitUsername', args), getBaseRefDefault: (args: { repoId: string hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), + }): Promise => invoke('repos:getBaseRefDefault', args), searchBaseRefs: (args: { repoId: string query: string limit?: number hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), + }): Promise => invoke('repos:searchBaseRefs', args), searchBaseRefDetails: (args: { repoId: string query: string limit?: number hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + }): Promise => invoke('repos:searchBaseRefDetails', args), onChanged: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() @@ -812,24 +797,23 @@ const api = { } satisfies PreloadApi['repos'], projects: { - list: () => ipcRenderer.invoke('projects:list'), - update: (args) => ipcRenderer.invoke('projects:update', args), - listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), - createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), - setupExistingFolder: (args) => - ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), - updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), - deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) + list: () => invoke('projects:list'), + update: (args) => invoke('projects:update', args), + listHostSetups: () => invoke('projectHostSetups:list'), + createHostSetup: (args) => invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => invoke('projectHostSetups:delete', args) } satisfies PreloadApi['projects'], projectGroups: { - list: () => ipcRenderer.invoke('projectGroups:list'), - create: (args) => ipcRenderer.invoke('projectGroups:create', args), - update: (args) => ipcRenderer.invoke('projectGroups:update', args), - delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), - moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), - scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), - cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), + list: () => invoke('projectGroups:list'), + create: (args) => invoke('projectGroups:create', args), + update: (args) => invoke('projectGroups:update', args), + delete: (args) => invoke('projectGroups:delete', args), + moveProject: (args) => invoke('projectGroups:moveProject', args), + scanNested: (args) => invoke('projectGroups:scanNested', args), + cancelNestedScan: (args) => invoke('projectGroups:cancelNestedScan', args), onNestedScanProgress: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -838,23 +822,23 @@ const api = { ipcRenderer.on('projectGroups:scanNestedProgress', listener) return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) }, - importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) + importNested: (args) => invoke('projectGroups:importNested', args) } satisfies PreloadApi['projectGroups'], folderWorkspaces: { - list: () => ipcRenderer.invoke('folderWorkspaces:list'), - getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), - create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), - update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), - delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) + list: () => invoke('folderWorkspaces:list'), + getPathStatus: (args) => invoke('folderWorkspaces:getPathStatus', args), + create: (args) => invoke('folderWorkspaces:create', args), + update: (args) => invoke('folderWorkspaces:update', args), + delete: (args) => invoke('folderWorkspaces:delete', args) } satisfies PreloadApi['folderWorkspaces'], sparsePresets: { - list: (args) => ipcRenderer.invoke('sparsePresets:list', args), + list: (args) => invoke('sparsePresets:list', args), - save: (args) => ipcRenderer.invoke('sparsePresets:save', args), + save: (args) => invoke('sparsePresets:save', args), - remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), + remove: (args) => invoke('sparsePresets:remove', args), onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => @@ -865,24 +849,26 @@ const api = { } satisfies PreloadApi['sparsePresets'], worktrees: { - list: (args) => ipcRenderer.invoke('worktrees:list', args), - listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), + list: (args) => invoke('worktrees:list', args), + listRetiredNames: (args) => invoke('worktrees:listRetiredNames', args), - listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + // Why the cast: `listDetected` is declared as an overload pair, and no single result type + // satisfies both signatures — the channel is one call either way. + listDetected: ((args) => + invoke('worktrees:listDetected', args)) as PreloadApi['worktrees']['listDetected'], - listKnownForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + listKnownForExecutionHost: (args) => invoke('worktrees:listKnownForExecutionHost', args), forgetRemovedForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), + invoke('worktrees:forgetRemovedForExecutionHost', args), - cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + cancelListDetected: (args) => invoke('worktrees:cancelListDetected', args), - listAll: () => ipcRenderer.invoke('worktrees:listAll'), + listAll: () => invoke('worktrees:listAll'), - create: (args) => ipcRenderer.invoke('worktrees:create', args), + create: (args) => invoke('worktrees:create', args), - adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), + adoptProvisionedRoot: (args) => invoke('worktrees:adoptProvisionedRoot', args), onCreateProgress: ( callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void @@ -895,32 +881,30 @@ const api = { return () => ipcRenderer.removeListener('createWorktree:progress', listener) }, - prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), + prefetchCreateBase: (args) => invoke('worktrees:prefetchCreateBase', args), - resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), + resolvePrBase: (args) => invoke('worktrees:resolvePrBase', args), - resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), + resolveMrBase: (args) => invoke('worktrees:resolveMrBase', args), - remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + remove: (args) => invoke('worktrees:remove', args), - forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + forgetLocal: (args) => invoke('worktrees:forgetLocal', args), - forceDeletePreservedBranch: (args) => - ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), + forceDeletePreservedBranch: (args) => invoke('worktrees:forceDeletePreservedBranch', args), - updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), + updateMeta: (args) => invoke('worktrees:updateMeta', args), - listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + listLineage: () => invoke('worktrees:listLineage'), listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), + invoke('worktrees:listLineageForHost', args), - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), + updateLineage: (args) => invoke('worktrees:updateLineage', args), - persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), + persistSortOrder: (args) => invoke('worktrees:persistSortOrder', args), - getBranchRenameFailureOutput: (args) => - ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), + getBranchRenameFailureOutput: (args) => invoke('worktrees:getBranchRenameFailureOutput', args), onChanged: ( callback: (data: { @@ -976,7 +960,7 @@ const api = { workspaceCleanup: { scan: (args, onProgress) => { if (!onProgress) { - return ipcRenderer.invoke('workspaceCleanup:scan', args) + return invoke('workspaceCleanup:scan', args) } const scanId = args?.scanId ?? crypto.randomUUID() const listener = ( @@ -988,28 +972,28 @@ const api = { } } ipcRenderer.on('workspaceCleanup:scanProgress', listener) - return ipcRenderer - .invoke('workspaceCleanup:scan', { ...args, scanId }) - .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + return invoke('workspaceCleanup:scan', { + ...args, + scanId + }).finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) }, - cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), - getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), - dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), - clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), - hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + cancelScan: (scanId) => invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => invoke('workspaceCleanup:getCachedScan'), + dismiss: (args) => invoke('workspaceCleanup:dismiss', args), + clearDismissals: () => invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args) => invoke('workspaceCleanup:hasKillableLocalProcesses', args), beginRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), recordRemovalSnapshotPrune: (args) => - ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), finishRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) + invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) } satisfies PreloadApi['workspaceCleanup'], workspaceSpace: { - analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), - getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), - cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), + analyze: () => invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => invoke('workspaceSpace:getCachedAnalysis'), + cancel: () => invoke('workspaceSpace:cancel'), onProgress: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1021,8 +1005,8 @@ const api = { } satisfies PreloadApi['workspaceSpace'], workspacePorts: { - scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), - kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + scan: (args) => invoke('workspacePorts:scan', args), + kill: (args) => invoke('workspacePorts:kill', args), onAdvertisedUrlChanged: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -1082,13 +1066,13 @@ const api = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true - }> => ipcRenderer.invoke('pty:spawn', opts), + }> => invoke('pty:spawn', opts), write: (id: string, data: string): void => { ipcRenderer.send('pty:write', { id, data }) }, writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + invoke('pty:writeAccepted', { id, data }), onWriteUnavailable: ( callback: (payload: { id: string @@ -1152,8 +1136,7 @@ const api = { /** Renderer-initiated delivery health/heal lane — rides invoke because the field wedge (v1.4.121-rc.0) kills main→renderer push while invoke stays alive. */ reportRendererDeliveryState: ( report: PtyRendererDeliveryStateReport - ): Promise => - ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + ): Promise => invoke('pty:reportRendererDeliveryState', report), /** Live pty:data listener count — the watchdog's "listener detached" vs "channel dead" discriminator. */ getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), rendererDispatcherReady: (): void => { @@ -1179,14 +1162,14 @@ const api = { }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => - ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), - listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), + listSessions: (): Promise => invoke('pty:listSessions'), getAuthoritativeBufferSnapshotCapabilities: ( ids: string[] ): Promise<{ id: string; authoritative: boolean | null }[]> => - ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), - hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), + hasPty: (id: string): Promise => invoke('pty:hasPty', { id }), getMainBufferSnapshot: ( id: string, @@ -1205,7 +1188,7 @@ const api = { pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number terminalOwner?: 'shell' - } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + } | null> => invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ pendingPtyCount: number @@ -1233,34 +1216,32 @@ const api = { lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean rendererDispatcherReadyForcedCount: number - }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), + }> => invoke('pty:getRendererDeliveryDebugSnapshot'), - resetRendererDeliveryDebug: (): Promise => - ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), + resetRendererDeliveryDebug: (): Promise => invoke('pty:resetRendererDeliveryDebug'), /** True if the PTY's shell has child processes (a running command); false at an idle prompt. */ - hasChildProcesses: (id: string): Promise => - ipcRenderer.invoke('pty:hasChildProcesses', { id }), + hasChildProcesses: (id: string): Promise => invoke('pty:hasChildProcesses', { id }), /** Return the PTY foreground process basename when available (e.g. "codex"). */ getForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:getForegroundProcess', { id }), + invoke('pty:getForegroundProcess', { id }), inspectProcess: ( id: string ): Promise<{ foregroundProcess: string | null hasChildProcesses: boolean unavailable?: true - }> => ipcRenderer.invoke('pty:inspectProcess', { id }), + }> => invoke('pty:inspectProcess', { id }), confirmForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + invoke('pty:confirmForegroundProcess', { id }), /** Resolve a PTY's live cwd via `/proc` (Linux) or `lsof` (macOS); `''` when unknown or unresolvable. */ - getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + getCwd: (id: string): Promise => invoke('pty:getCwd', { id }), /** The PTY's last APPLIED size (real winsize), or null if unknown — lets the renderer detect drift after a dropped resize and re-assert. */ getSize: (id: string): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('pty:getSize', { id }), + invoke('pty:getSize', { id }), onData: ( callback: (data: { @@ -1316,7 +1297,7 @@ const api = { /** Title-only replay snapshot on (re)attach — attention facts (bells/completions) never replay. */ getSideEffectSnapshot: (id: string): Promise => - ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + invoke('pty:sideEffectSnapshot', { id }), onExit: ( callback: (data: { @@ -1388,23 +1369,23 @@ const api = { // Claim serializer ownership before spawn; echo the generation token on settle/clear to prevent pane-key reuse races. declarePendingPaneSerializer: (paneKey: string): Promise => - ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + invoke('pty:declarePendingPaneSerializer', { paneKey }), settlePaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + invoke('pty:settlePaneSerializer', { paneKey, gen }), clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), reportRendererSerializerReady: (ptyId: string): Promise => - ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + invoke('pty:reportRendererSerializerReady', { ptyId }), management: { - listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), - killAll: () => ipcRenderer.invoke('pty:management:killAll'), - killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart'), - macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') + listSessions: () => invoke('pty:management:listSessions'), + killAll: () => invoke('pty:management:killAll'), + killOne: (args: { sessionId: string }) => invoke('pty:management:killOne', args), + restart: () => invoke('pty:management:restart'), + macTccAttribution: () => invoke('pty:management:macTccAttribution') } }, @@ -1417,23 +1398,22 @@ const api = { images?: { contentType: string; data: Uint8Array }[] }): Promise< { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } - > => ipcRenderer.invoke('feedback:submit', args) + > => invoke('feedback:submit', args) }, crashReports: { - getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), - getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), - dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + getLatestPending: () => invoke('crashReports:getLatestPending'), + getLatestReport: () => invoke('crashReports:getLatestReport'), + dismiss: (args: { reportId: string }) => invoke('crashReports:dismiss', args), recordRendererError: ( args: ReactErrorBoundaryReportArgs - ): Promise => - ipcRenderer.invoke('crashReports:recordRendererError', args), + ): Promise => invoke('crashReports:recordRendererError', args), recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => ipcRenderer.send('crashReports:recordBreadcrumb', args), submit: (args: CrashReportSubmitArgs): Promise => - ipcRenderer.invoke('crashReports:submit', args), + invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + invoke('crashReports:copyLatestDiagnostics', args), readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), readProcessMemory: (): Promise => readRendererProcessMemory() }, @@ -1444,17 +1424,17 @@ const api = { title: string }): Promise< { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } - > => ipcRenderer.invoke('export:html-to-pdf', args) + > => invoke('export:html-to-pdf', args) }, gh: { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), + viewer: (): Promise => invoke('gh:viewer'), repoSlug: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoSlug', args), + invoke('gh:repoSlug', args), repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoUpstream', args), + invoke('gh:repoUpstream', args), prForBranch: (args: { repoPath: string @@ -1464,21 +1444,21 @@ const api = { fallbackPRNumber?: number | null acceptMergedFallbackPR?: boolean currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), + }): Promise => invoke('gh:prForBranch', args), refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => - ipcRenderer.invoke('gh:refreshPRNow', args), + invoke('gh:refreshPRNow', args), enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + }): Promise => invoke('gh:enqueuePRRefresh', args), reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + }): Promise => invoke('gh:reportVisiblePRRefreshCandidates', args), onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => @@ -1492,7 +1472,7 @@ const api = { repoId?: string sourceContext?: TaskSourceContext | null number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), + }): Promise => invoke('gh:issue', args), workItem: (args: { repoPath: string @@ -1500,7 +1480,7 @@ const api = { sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), + }): Promise => invoke('gh:workItem', args), workItemByOwnerRepo: (args: { repoPath: string @@ -1510,7 +1490,7 @@ const api = { host?: string number: number type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + }): Promise => invoke('gh:workItemByOwnerRepo', args), workItemDetails: (args: { repoPath: string @@ -1518,14 +1498,14 @@ const api = { sourceContext?: TaskSourceContext | null number: number type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + }): Promise => invoke('gh:workItemDetails', args), notifyWorkItemMutated: (args: { repoPath: string repoId?: string type: 'issue' | 'pr' number: number - }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), + }): Promise => invoke('gh:notifyWorkItemMutated', args), prFileContents: (args: { repoPath: string @@ -1538,10 +1518,10 @@ const api = { status: string headSha: string baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), + }): Promise => invoke('gh:prFileContents', args), listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => - ipcRenderer.invoke('gh:listIssues', args), + invoke('gh:listIssues', args), createIssue: (args: { repoPath: string @@ -1551,13 +1531,13 @@ const api = { body: string labels?: string[] assignees?: string[] - }): Promise => ipcRenderer.invoke('gh:createIssue', args), + }): Promise => invoke('gh:createIssue', args), countWorkItems: (args: { repoPath: string repoId?: string query?: string - }): Promise => ipcRenderer.invoke('gh:countWorkItems', args), + }): Promise => invoke('gh:countWorkItems', args), listWorkItems: (args: { repoPath: string @@ -1567,7 +1547,7 @@ const api = { page?: number noCache?: boolean }): Promise>> => - ipcRenderer.invoke('gh:listWorkItems', args), + invoke('gh:listWorkItems', args), prChecks: (args: { repoPath: string @@ -1577,7 +1557,7 @@ const api = { headSha?: string prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), + }): Promise => invoke('gh:prChecks', args), prCheckDetails: (args: { repoPath: string @@ -1588,7 +1568,7 @@ const api = { checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + }): Promise => invoke('gh:prCheckDetails', args), rerunPRChecks: (args: { repoPath: string @@ -1599,7 +1579,7 @@ const api = { failedOnly?: boolean prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:rerunPRChecks', args), + invoke('gh:rerunPRChecks', args), prComments: (args: { repoPath: string @@ -1608,7 +1588,7 @@ const api = { prNumber: number prRepo?: GitHubOwnerRepo | null noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), + }): Promise => invoke('gh:prComments', args), setPRCommentReaction: (args: { repoPath: string @@ -1618,7 +1598,7 @@ const api = { content: GitHubReactionContent reacted: boolean prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + }): Promise => invoke('gh:setPRCommentReaction', args), resolveReviewThread: (args: { repoPath: string @@ -1627,7 +1607,7 @@ const api = { threadId: string resolve: boolean prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + }): Promise => invoke('gh:resolveReviewThread', args), setPRFileViewed: (args: { repoPath: string @@ -1638,7 +1618,7 @@ const api = { pullRequestId: string path: string viewed: boolean - }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), + }): Promise => invoke('gh:setPRFileViewed', args), updatePRTitle: (args: { repoPath: string @@ -1646,7 +1626,7 @@ const api = { prNumber: number title: string prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), + }): Promise => invoke('gh:updatePRTitle', args), mergePR: (args: { repoPath: string @@ -1655,8 +1635,7 @@ const api = { prNumber: number method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:mergePR', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gh:mergePR', args), setPRAutoMerge: (args: { repoPath: string @@ -1666,8 +1645,7 @@ const api = { enabled: boolean method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:setPRAutoMerge', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gh:setPRAutoMerge', args), updatePRState: (args: { repoPath: string @@ -1676,8 +1654,7 @@ const api = { prNumber: number updates: { state: 'open' | 'closed' } prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updatePRState', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gh:updatePRState', args), markPRReadyForReview: (args: { repoPath: string @@ -1686,7 +1663,7 @@ const api = { prNumber: number prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:markPRReadyForReview', args), + invoke('gh:markPRReadyForReview', args), requestPRReviewers: (args: { repoPath: string @@ -1696,7 +1673,7 @@ const api = { reviewers: string[] prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:requestPRReviewers', args), + invoke('gh:requestPRReviewers', args), removePRReviewers: (args: { repoPath: string @@ -1706,7 +1683,7 @@ const api = { reviewers: string[] prRepo?: GitHubOwnerRepo | null }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:removePRReviewers', args), + invoke('gh:removePRReviewers', args), updateIssue: (args: { repoPath: string @@ -1714,8 +1691,7 @@ const api = { sourceContext?: TaskSourceContext | null number: number updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updateIssue', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('gh:updateIssue', args), addIssueComment: (args: { repoPath: string @@ -1725,7 +1701,7 @@ const api = { body: string type?: 'issue' | 'pr' prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), + }): Promise => invoke('gh:addIssueComment', args), addPRReviewCommentReply: (args: { repoPath: string @@ -1738,7 +1714,7 @@ const api = { path?: string line?: number prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), + }): Promise => invoke('gh:addPRReviewCommentReply', args), addPRReviewComment: (args: { repoPath: string @@ -1751,19 +1727,19 @@ const api = { line: number startLine?: number body: string - }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), + }): Promise => invoke('gh:addPRReviewComment', args), listLabels: (args: { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listLabels', args), + }): Promise => invoke('gh:listLabels', args), listAssignableUsers: (args: { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), + }): Promise => invoke('gh:listAssignableUsers', args), // Why: renderer owns the work-item cache; main fires this for non-origin mutations only (origin callers updated optimistically). See src/main/ipc/github.ts. onWorkItemMutated: ( @@ -1782,78 +1758,69 @@ const api = { return () => ipcRenderer.removeListener('gh:workItemMutated', listener) }, - checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (source: AppStarSource): Promise => - ipcRenderer.invoke('gh:starOrca', source), + checkOrcaStarred: (): Promise => invoke('gh:checkOrcaStarred'), + starOrca: (source: AppStarSource): Promise => invoke('gh:starOrca', source), // Why: rate_limit is exempt from rate-limit accounting; `force` still busts the 30s in-process cache after an expensive op. rateLimit: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('gh:rateLimit', args), + invoke('gh:rateLimit', args), diagnoseAuth: (args?: { host?: string }): Promise => - ipcRenderer.invoke('gh:diagnoseAuth', args), + invoke('gh:diagnoseAuth', args), // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── listAccessibleProjects: ( args?: ListAccessibleProjectsArgs - ): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects', args), + ): Promise => invoke('gh:listAccessibleProjects', args), resolveProjectRef: (args: ResolveProjectRefArgs): Promise => - ipcRenderer.invoke('gh:resolveProjectRef', args), + invoke('gh:resolveProjectRef', args), listProjectViews: (args: ListProjectViewsArgs): Promise => - ipcRenderer.invoke('gh:listProjectViews', args), + invoke('gh:listProjectViews', args), getProjectViewTable: (args: GetProjectViewTableArgs): Promise => - ipcRenderer.invoke('gh:getProjectViewTable', args), + invoke('gh:getProjectViewTable', args), projectWorkItemDetailsBySlug: ( args: ProjectWorkItemDetailsBySlugArgs ): Promise => - ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + invoke('gh:projectWorkItemDetailsBySlug', args), updateProjectItemField: ( args: UpdateProjectItemFieldArgs - ): Promise => - ipcRenderer.invoke('gh:updateProjectItemField', args), + ): Promise => invoke('gh:updateProjectItemField', args), clearProjectItemField: ( args: ClearProjectItemFieldArgs - ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), + ): Promise => invoke('gh:clearProjectItemField', args), updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => - ipcRenderer.invoke('gh:updateIssueBySlug', args), + invoke('gh:updateIssueBySlug', args), updatePullRequestBySlug: ( args: UpdatePullRequestBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + ): Promise => invoke('gh:updatePullRequestBySlug', args), addIssueCommentBySlug: ( args: AddIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + ): Promise => invoke('gh:addIssueCommentBySlug', args), updateIssueCommentBySlug: ( args: UpdateIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + ): Promise => invoke('gh:updateIssueCommentBySlug', args), deleteIssueCommentBySlug: ( args: DeleteIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + ): Promise => invoke('gh:deleteIssueCommentBySlug', args), listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => - ipcRenderer.invoke('gh:listLabelsBySlug', args), + invoke('gh:listLabelsBySlug', args), listAssignableUsersBySlug: ( args: ListAssignableUsersBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + ): Promise => invoke('gh:listAssignableUsersBySlug', args), listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => - ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + invoke('gh:listIssueTypesBySlug', args), updateIssueTypeBySlug: ( args: UpdateIssueTypeBySlugArgs - ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) + ): Promise => invoke('gh:updateIssueTypeBySlug', args) }, hostedReview: { forBranch: (args: HostedReviewForBranchArgs): Promise => - ipcRenderer.invoke('hostedReview:forBranch', args), + invoke('hostedReview:forBranch', args), getCreationEligibility: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) + invoke('hostedReview:getCreationEligibility', args), + create: (args: unknown): Promise => invoke('hostedReview:create', args), + createStacked: (args: unknown): Promise => invoke('hostedReview:createStacked', args) }, // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. @@ -1867,44 +1834,44 @@ const api = { apiToken?: string | null baseUrl?: string | null }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => - ipcRenderer.invoke('bitbucket:connect', args), + invoke('bitbucket:connect', args), - disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + disconnect: (): Promise => invoke('bitbucket:disconnect'), - status: (): Promise => ipcRenderer.invoke('bitbucket:status') + status: (): Promise => invoke('bitbucket:status') }, linear: { connect: (args: { apiKey: string }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), + invoke('linear:connect', args), disconnect: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:disconnect', args), + invoke('linear:disconnect', args), selectWorkspace: (args: { workspaceId: string }): Promise => - ipcRenderer.invoke('linear:selectWorkspace', args), + invoke('linear:selectWorkspace', args), - status: (): Promise => ipcRenderer.invoke('linear:status'), + status: (): Promise => invoke('linear:status'), testConnection: (args?: { workspaceId?: string }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:testConnection', args), + invoke('linear:testConnection', args), searchIssues: (args: { query: string limit?: number workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + }): Promise => invoke('linear:searchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'created' | 'all' | 'completed' limit?: number workspaceId?: string attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), + }): Promise => invoke('linear:listIssues', args), createIssue: (args: { teamId: string @@ -1920,37 +1887,36 @@ const api = { }): Promise< | { ok: true; id: string; identifier: string; title: string; url: string } | { ok: false; error: string } - > => ipcRenderer.invoke('linear:createIssue', args), + > => invoke('linear:createIssue', args), getIssue: (args: { id: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:getIssue', args), + invoke('linear:getIssue', args), updateIssue: (args: { id: string updates: unknown workspaceId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:updateIssue', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('linear:updateIssue', args), addIssueComment: (args: { issueId: string body: string workspaceId?: string }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:addIssueComment', args), + invoke('linear:addIssueComment', args), issueComments: (args: { issueId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:issueComments', args), + invoke('linear:issueComments', args), listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), + invoke('linear:listTeams', args), listProjects: (args?: { query?: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), + }): Promise => invoke('linear:listProjects', args), createProject: (args: { name: string @@ -1965,54 +1931,54 @@ const api = { startDate?: string targetDate?: string }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:createProject', args), + invoke('linear:createProject', args), getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => - ipcRenderer.invoke('linear:getProject', args), + invoke('linear:getProject', args), listProjectIssues: (args: { projectId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + }): Promise => invoke('linear:listProjectIssues', args), listCustomViews: (args: { model: string limit?: number workspaceId?: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + }): Promise => invoke('linear:listCustomViews', args), getCustomView: (args: { viewId: string model: string workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + }): Promise => invoke('linear:getCustomView', args), listCustomViewIssues: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + }): Promise => invoke('linear:listCustomViewIssues', args), listCustomViewProjects: (args: { viewId: string limit?: number workspaceId: string force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + }): Promise => invoke('linear:listCustomViewProjects', args), teamStates: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamStates', args), + invoke('linear:teamStates', args), teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamLabels', args), + invoke('linear:teamLabels', args), teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamMembers', args) + invoke('linear:teamMembers', args) }, jira: { @@ -2022,48 +1988,46 @@ const api = { apiToken: string authType?: 'cloud' | 'server' }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), + invoke('jira:connect', args), - disconnect: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:disconnect', args), + disconnect: (args?: { siteId?: string }): Promise => invoke('jira:disconnect', args), - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), + selectSite: (args: { siteId: string }): Promise => invoke('jira:selectSite', args), - status: (): Promise => ipcRenderer.invoke('jira:status'), + status: (): Promise => invoke('jira:status'), - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + readStatus: (): Promise => invoke('jira:readStatus'), testConnection: (args?: { siteId?: string }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), + invoke('jira:testConnection', args), searchIssues: (args: { jql: string limit?: number siteId?: string requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + }): Promise => invoke('jira:searchIssues', args), cancelSearchIssues: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelSearchIssues', args), + invoke('jira:cancelSearchIssues', args), listIssues: (args?: { filter?: 'assigned' | 'reported' | 'all' | 'done' limit?: number siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), + }): Promise => invoke('jira:listIssues', args), getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), + invoke('jira:getIssue', args), lookupIssueSummary: (args: { key: string siteId: string requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + }): Promise => invoke('jira:lookupIssueSummary', args), cancelIssueSummary: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelIssueSummary', args), + invoke('jira:cancelIssueSummary', args), createIssue: (args: { siteId?: string @@ -2074,52 +2038,51 @@ const api = { customFields?: Record }): Promise< { ok: true; id: string; key: string; url: string } | { ok: false; error: string } - > => ipcRenderer.invoke('jira:createIssue', args), + > => invoke('jira:createIssue', args), updateIssue: (args: { key: string updates: unknown siteId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:updateIssue', args), + }): Promise<{ ok: true } | { ok: false; error: string }> => invoke('jira:updateIssue', args), addIssueComment: (args: { key: string body: string siteId?: string }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:addIssueComment', args), + invoke('jira:addIssueComment', args), issueComments: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:issueComments', args), + invoke('jira:issueComments', args), listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), + invoke('jira:listProjects', args), listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listIssueTypes', args), + invoke('jira:listIssueTypes', args), listCreateFields: (args: { projectIdOrKey: string issueTypeId: string siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + }): Promise => invoke('jira:listCreateFields', args), listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), + invoke('jira:listPriorities', args), listAssignableUsers: (args: { key: string query?: string siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + }): Promise => invoke('jira:listAssignableUsers', args), listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args), + invoke('jira:listTransitions', args), getProjectStatusOrder: (args: { projectKey: string siteId?: string - }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) + }): Promise => invoke('jira:getProjectStatusOrder', args) }, starNag: { @@ -2138,69 +2101,64 @@ const api = { ipcRenderer.on('star-nag:hide', listener) return () => ipcRenderer.removeListener('star-nag:hide', listener) }, - dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), - later: (): Promise => ipcRenderer.invoke('star-nag:later'), - complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), - disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), - openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), - starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), - forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), + dismiss: (): Promise => invoke('star-nag:dismiss'), + later: (): Promise => invoke('star-nag:later'), + complete: (): Promise => invoke('star-nag:complete'), + disable: (): Promise => invoke('star-nag:disable'), + openWeb: (): Promise => invoke('star-nag:openWeb'), + starOrca: (): Promise => invoke('star-nag:starOrca'), + forceShow: (): Promise => invoke('star-nag:forceShow'), agentValueMoment: (): Promise< { status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' } - > => ipcRenderer.invoke('star-nag:agentValueMoment'), - showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), - onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') + > => invoke('star-nag:agentValueMoment'), + showAgentValueMoment: (): Promise => invoke('star-nag:showAgentValueMoment'), + onboardingCompleted: (): Promise => invoke('star-nag:onboardingCompleted') }, // Why: main validates telemetry; renderer call sites use typed wrappers. telemetryTrack: (name: string, props: Record): Promise => - ipcRenderer.invoke('telemetry:track', name, props), - telemetrySetOptIn: (optedIn: boolean): Promise => - ipcRenderer.invoke('telemetry:setOptIn', optedIn), - telemetryAcknowledgeBanner: (): Promise => - ipcRenderer.invoke('telemetry:acknowledgeBanner'), + invoke('telemetry:track', name, props), + telemetrySetOptIn: (optedIn: boolean): Promise => invoke('telemetry:setOptIn', optedIn), + telemetryAcknowledgeBanner: (): Promise => invoke('telemetry:acknowledgeBanner'), telemetryGetConsentState: (): Promise => - ipcRenderer.invoke('telemetry:getConsentState'), + invoke('telemetry:getConsentState'), // Why: bridges are deliberately loose — main type-narrows this untrusted renderer input (see telemetry-error-tracking.md). diagnostics: { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), + getStatus: (): Promise => invoke('diagnostics:getStatus'), collectBundle: (lookbackMinutes?: number): Promise => - ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), + invoke('diagnostics:collectBundle', lookbackMinutes), openBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), + invoke('diagnostics:openBundlePreview', bundleSubmissionId), discardBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), + invoke('diagnostics:discardBundlePreview', bundleSubmissionId), uploadBundle: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), - deleteBundle: (ticketId: string): Promise => - ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) + invoke('diagnostics:uploadBundle', bundleSubmissionId), + deleteBundle: (ticketId: string): Promise => invoke('diagnostics:deleteBundle', ticketId) }, settings: { - get: (): Promise => ipcRenderer.invoke('settings:get'), + get: (): Promise => invoke('settings:get'), // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), + set: (args: Record): Promise => invoke('settings:set', args), setActiveRuntimeEnvironmentPreference: (args: { environmentId: string | null - }): Promise => - ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), + }): Promise => invoke('settings:set-active-runtime-environment-preference', args), updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => - ipcRenderer.invoke('settings:update-pr-bot-author-override', args), + invoke('settings:update-pr-bot-author-override', args), - listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), + listFonts: (): Promise => invoke('settings:listFonts'), previewGhosttyImport: (): Promise => - ipcRenderer.invoke('settings:previewGhosttyImport'), + invoke('settings:previewGhosttyImport'), previewWarpThemeImport: (source: WarpThemeImportSource): Promise => - ipcRenderer.invoke('settings:previewWarpThemeImport', source), + invoke('settings:previewWarpThemeImport', source), onChanged: (callback: (updates: Record) => void): (() => void) => { const listener = ( @@ -2213,7 +2171,7 @@ const api = { }, agentAwake: { - getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + getStatus: (): Promise => invoke('agentAwake:getStatus'), onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => callback(status) @@ -2224,19 +2182,19 @@ const api = { localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute): Promise => - ipcRenderer.invoke('localhostWorktreeLabels:register', args) + invoke('localhostWorktreeLabels:register', args) } satisfies PreloadApi['localhostWorktreeLabels'], keybindings: { - get: (): Promise => ipcRenderer.invoke('keybindings:get'), - ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), + get: (): Promise => invoke('keybindings:get'), + ensureFile: (): Promise => invoke('keybindings:ensureFile'), setAction: (args: { actionId: KeybindingActionId bindings: string[] | null - }): Promise => ipcRenderer.invoke('keybindings:setAction', args), - reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), - openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), - revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), + }): Promise => invoke('keybindings:setAction', args), + reload: (): Promise => invoke('keybindings:reload'), + openFile: (): Promise => invoke('keybindings:openFile'), + revealFile: (): Promise => invoke('keybindings:revealFile'), onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -2248,20 +2206,19 @@ const api = { }, codexAccounts: { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), + list: (): Promise => invoke('codexAccounts:list'), add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:add', args), + invoke('codexAccounts:add', args), reauthenticate: (args: { accountId: string activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), + }): Promise => invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => invoke('codexAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + }): Promise => invoke('codexAccounts:select', args), listStalePanes: (args: { ptyIds: string[] }): Promise< @@ -2271,71 +2228,61 @@ const api = { activeAccountId: string | null reason?: 'account-change' | 'home-route-change' }[] - > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + > => invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => - ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), + invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => - ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) + invoke('codexAccounts:forgetStalePanes', args) }, claudeAccounts: { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), + list: (): Promise => invoke('claudeAccounts:list'), add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:add', args), - cancelPendingLogin: (): Promise => - ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), + invoke('claudeAccounts:add', args), + cancelPendingLogin: (): Promise => invoke('claudeAccounts:cancelPendingLogin'), reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:reauthenticate', args), + invoke('claudeAccounts:reauthenticate', args), remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), + invoke('claudeAccounts:remove', args), select: (args: { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) + }): Promise => invoke('claudeAccounts:select', args) }, cli: { - getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), - install: (): Promise => ipcRenderer.invoke('cli:install'), - remove: (): Promise => ipcRenderer.invoke('cli:remove'), + getInstallStatus: (): Promise => invoke('cli:getInstallStatus'), + install: (): Promise => invoke('cli:install'), + remove: (): Promise => invoke('cli:remove'), getWslInstallStatus: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:getWslInstallStatus', args), + invoke('cli:getWslInstallStatus', args), installWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:installWsl', args), + invoke('cli:installWsl', args), removeWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:removeWsl', args) + invoke('cli:removeWsl', args) }, codexConfigSync: { - status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') + status: (): Promise => invoke('codexConfigSync:status') }, agentHooks: { - claudeStatus: (): Promise => - ipcRenderer.invoke('agentHooks:claudeStatus'), - openClaudeStatus: (): Promise => - ipcRenderer.invoke('agentHooks:openClaudeStatus'), - codexStatus: (): Promise => - ipcRenderer.invoke('agentHooks:codexStatus'), - geminiStatus: (): Promise => - ipcRenderer.invoke('agentHooks:geminiStatus'), + claudeStatus: (): Promise => invoke('agentHooks:claudeStatus'), + openClaudeStatus: (): Promise => invoke('agentHooks:openClaudeStatus'), + codexStatus: (): Promise => invoke('agentHooks:codexStatus'), + geminiStatus: (): Promise => invoke('agentHooks:geminiStatus'), antigravityStatus: (): Promise => - ipcRenderer.invoke('agentHooks:antigravityStatus'), - ampStatus: (): Promise => ipcRenderer.invoke('agentHooks:ampStatus'), - cursorStatus: (): Promise => - ipcRenderer.invoke('agentHooks:cursorStatus'), - droidStatus: (): Promise => - ipcRenderer.invoke('agentHooks:droidStatus'), + invoke('agentHooks:antigravityStatus'), + ampStatus: (): Promise => invoke('agentHooks:ampStatus'), + cursorStatus: (): Promise => invoke('agentHooks:cursorStatus'), + droidStatus: (): Promise => invoke('agentHooks:droidStatus'), commandCodeStatus: (): Promise => - ipcRenderer.invoke('agentHooks:commandCodeStatus'), - grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus'), - devinStatus: (): Promise => - ipcRenderer.invoke('agentHooks:devinStatus'), - copilotStatus: (): Promise => - ipcRenderer.invoke('agentHooks:copilotStatus'), - hermesStatus: (): Promise => - ipcRenderer.invoke('agentHooks:hermesStatus'), - kimiStatus: (): Promise => ipcRenderer.invoke('agentHooks:kimiStatus') + invoke('agentHooks:commandCodeStatus'), + grokStatus: (): Promise => invoke('agentHooks:grokStatus'), + devinStatus: (): Promise => invoke('agentHooks:devinStatus'), + copilotStatus: (): Promise => invoke('agentHooks:copilotStatus'), + hermesStatus: (): Promise => invoke('agentHooks:hermesStatus'), + kimiStatus: (): Promise => invoke('agentHooks:kimiStatus') }, agentTrust: { @@ -2343,7 +2290,7 @@ const api = { preset: 'cursor' | 'copilot' | 'codex' workspacePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) + }): Promise => invoke('agentTrust:markTrusted', args) }, preflight: { @@ -2369,13 +2316,13 @@ const api = { tokenConfigured: boolean } linear: { connected: boolean } - }> => ipcRenderer.invoke('preflight:check', args), + }> => invoke('preflight:check', args), detectAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:detectAgents', args), + invoke('preflight:detectAgents', args), refreshAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), + invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args), + invoke('preflight:detectRemoteAgents', args), detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }): Promise<{ @@ -2384,19 +2331,19 @@ const api = { pwshAvailable: boolean gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null - }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) + }> => invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) }, notifications: { dispatch: (args: Record): Promise => - ipcRenderer.invoke('notifications:dispatch', args), + invoke('notifications:dispatch', args), dismiss: (ids: string[]): Promise => - ipcRenderer.invoke('notifications:dismiss', ids), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + invoke('notifications:dismiss', ids), + openSystemSettings: (): Promise => invoke('notifications:openSystemSettings'), getPermissionStatus: (): Promise => - ipcRenderer.invoke('notifications:getPermissionStatus'), + invoke('notifications:getPermissionStatus'), probeDelivery: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('notifications:probeDelivery', args), + invoke('notifications:probeDelivery', args), playSound: async (options?: { force?: boolean volume?: number @@ -2407,7 +2354,7 @@ const api = { return { played: false, reason: 'deduped' } } - const resolved = (await ipcRenderer.invoke( + const resolved = (await invoke( 'notifications:resolveSoundPath' )) as NotificationSoundPathResult if (!resolved.ok) { @@ -2419,9 +2366,7 @@ const api = { let entry = cachedNotificationSound if (!entry || entry.path !== resolved.path) { - const sound = (await ipcRenderer.invoke( - 'notifications:loadSound' - )) as NotificationSoundDataResult + const sound = (await invoke('notifications:loadSound')) as NotificationSoundDataResult if (!sound.ok) { disposeCachedNotificationSound() return { played: false, reason: sound.reason } @@ -2472,23 +2417,22 @@ const api = { }, onboarding: { - get: (): Promise => ipcRenderer.invoke('onboarding:get'), + get: (): Promise => invoke('onboarding:get'), update: ( updates: Partial> & { checklist?: Partial } - ): Promise => ipcRenderer.invoke('onboarding:update', updates) + ): Promise => invoke('onboarding:update', updates) }, dashboard: { // Open the pop-out dashboard window, or focus it if already open. - openPopout: (view?: 'board' | 'map'): Promise => - ipcRenderer.invoke('dashboardPopout:open', view), + openPopout: (view?: 'board' | 'map'): Promise => invoke('dashboardPopout:open', view), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => - ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), - getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => invoke('dashboard:getPopoutOpen'), onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) ipcRenderer.on('dashboard:popoutOpenChanged', listener) @@ -2527,7 +2471,7 @@ const api = { }, // ── Consumer side (pop-out window) ─────────────────────────────────── - requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + requestSnapshot: (): Promise => invoke('dashboard:requestSnapshot'), onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => callback(snapshot) @@ -2541,33 +2485,30 @@ const api = { return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) }, revealAgent: (args: DashboardRevealAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:revealAgent', args), - ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => invoke('dashboardPopout:ackAgent', { paneKey }), spawnAgent: (args: DashboardSpawnAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + invoke('dashboardPopout:spawnAgent', args), sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => - ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) + invoke('dashboardPopout:sleepWorkspace', args) }, terminalPreview: { connect: ( ptyId: string, opts?: { scrollbackRows?: number } - ): Promise => - ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + ): Promise => invoke('terminalPreview:connect', { ptyId, opts }), input: (ptyId: string, data: string): Promise => - ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + invoke('terminalPreview:input', { ptyId, data }), fit: ( ptyId: string, cols: number, rows: number ): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), + invoke('terminalPreview:fit', { ptyId, cols, rows }), ack: (ptyId: string, bytes: number): Promise => - ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), - unsubscribe: (ptyId: string): Promise => - ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => invoke('terminalPreview:unsubscribe', { ptyId }), onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -2586,138 +2527,131 @@ const api = { return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) }, consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => - ipcRenderer.invoke('macosTccPrompts:consumePending'), + invoke('macosTccPrompts:consumePending'), acknowledgePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), + invoke('macosTccPrompts:acknowledgePending', claimId), releasePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), - dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') + invoke('macosTccPrompts:releasePending', claimId), + dismiss: (): Promise => invoke('macosTccPrompts:dismiss') }, developerPermissions: { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), + getStatus: (): Promise => invoke('developerPermissions:getStatus'), request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), + invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args), + invoke('developerPermissions:openSettings', args), testLocalNetworkConnection: (args: { host: string; port: number }): Promise => - ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) + invoke('developerPermissions:testLocalNetworkConnection', args) }, computerUsePermissions: { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), + getStatus: (): Promise => invoke('computerUsePermissions:getStatus'), openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') + invoke('computerUsePermissions:openSetup', args), + reset: (): Promise => invoke('computerUsePermissions:reset') }, shell: { - openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), + openPath: (path: string): Promise => invoke('shell:openPath', path), openInFileManager: (path: string): Promise => - ipcRenderer.invoke('shell:openInFileManager', path), + invoke('shell:openInFileManager', path), openInExternalEditor: ( request: ShellOpenExternalEditorRequest - ): Promise => - ipcRenderer.invoke('shell:openInExternalEditor', request), + ): Promise => invoke('shell:openInExternalEditor', request), - openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), + openUrl: (url: string): Promise => invoke('shell:openUrl', url), - openFilePath: (path: string): Promise => - ipcRenderer.invoke('shell:openFilePath', path), + openFilePath: (path: string): Promise => invoke('shell:openFilePath', path), - openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + openFileUri: (uri: string): Promise => invoke('shell:openFileUri', uri), - pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), + pathExists: (path: string): Promise => invoke('shell:pathExists', path), - pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + pickAttachment: (): Promise => invoke('shell:pickAttachment'), - pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + pickImage: (): Promise => invoke('shell:pickImage'), pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => - ipcRenderer.invoke('shell:pickRepoIconImage'), + invoke('shell:pickRepoIconImage'), - pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + pickAudio: (): Promise => invoke('shell:pickAudio'), pickDirectory: (args: { defaultPath?: string }): Promise => - ipcRenderer.invoke('shell:pickDirectory', args), + invoke('shell:pickDirectory', args), copyFile: (args: { srcPath: string; destPath: string }): Promise => - ipcRenderer.invoke('shell:copyFile', args) + invoke('shell:copyFile', args) }, skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target), - freshnessInventory: (): Promise => - ipcRenderer.invoke('skills:freshnessInventory'), + invoke('skills:discover', target), + freshnessInventory: (): Promise => invoke('skills:freshnessInventory'), startUpdateRun: (names: string[]): Promise => - ipcRenderer.invoke('skills:startUpdateRun', names), - cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), - acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), - getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + invoke('skills:startUpdateRun', names), + cancelUpdateRun: (): Promise => invoke('skills:cancelUpdateRun'), + acknowledgeUpdateRun: (): Promise => invoke('skills:acknowledgeUpdateRun'), + getUpdateRun: (): Promise => invoke('skills:getUpdateRun'), prepareShare: (input: { skillIds: string[] bundleName: string target?: SkillDiscoveryTarget packageId?: string - }): Promise => ipcRenderer.invoke('skills:prepareShare', input), + }): Promise => invoke('skills:prepareShare', input), publishShare: (input: SkillSharePublishInput): Promise => - ipcRenderer.invoke('skills:publishShare', input), + invoke('skills:publishShare', input), cancelShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:cancelShare', preparationId), + invoke('skills:cancelShare', preparationId), releaseShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:releaseShare', preparationId), + invoke('skills:releaseShare', preparationId), resolveShare: (shareId: string): Promise => - ipcRenderer.invoke('skills:resolveShare', shareId), + invoke('skills:resolveShare', shareId), installShare: (input: SkillShareInstallInput): Promise => - ipcRenderer.invoke('skills:installShare', input), + invoke('skills:installShare', input), installBundleShare: ( input: SkillBundleShareInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundleShare', input), + ): Promise => invoke('skills:installBundleShare', input), installBundlePackageVersion: ( input: SkillBundlePackageVersionInstallInput ): Promise => - ipcRenderer.invoke('skills:installBundlePackageVersion', input), + invoke('skills:installBundlePackageVersion', input), installPackageVersion: ( input: SkillPackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installPackageVersion', input), + ): Promise => invoke('skills:installPackageVersion', input), cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => - ipcRenderer.invoke('skills:cancelInstall', input), + invoke('skills:cancelInstall', input), previewInstall: (input: SkillInstallPreviewInput): Promise => - ipcRenderer.invoke('skills:previewInstall', input), + invoke('skills:previewInstall', input), previewBundleInstall: ( input: SkillBundleInstallPreviewInput - ): Promise => - ipcRenderer.invoke('skills:previewBundleInstall', input), + ): Promise => invoke('skills:previewBundleInstall', input), removeInstall: (input: SkillRemoveInput): Promise => - ipcRenderer.invoke('skills:removeInstall', input), + invoke('skills:removeInstall', input), // Desktop always registers the delete IPC handlers in its own main process. deleteSupported: (): Promise => Promise.resolve(true), previewDelete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:previewDelete', request), + invoke('skills:previewDelete', request), delete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:delete', request), + invoke('skills:delete', request), listManagedInstalls: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listManagedInstalls', environmentId), + invoke('skills:listManagedInstalls', environmentId), getPackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:getPackage', packageId), + invoke('skills:getPackage', packageId), listOwnedShares: (): Promise> => - ipcRenderer.invoke('skills:listOwnedShares'), + invoke('skills:listOwnedShares'), revokeShare: (shareId: string): Promise> => - ipcRenderer.invoke('skills:revokeShare', shareId), + invoke('skills:revokeShare', shareId), deletePackageVersion: (input: { packageId: string versionId: string - }): Promise> => - ipcRenderer.invoke('skills:deletePackageVersion', input), + }): Promise> => invoke('skills:deletePackageVersion', input), deletePackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:deletePackage', packageId), + invoke('skills:deletePackage', packageId), listWslDistros: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listWslDistros', environmentId), + invoke('skills:listWslDistros', environmentId), onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => callback(progress) @@ -2739,12 +2673,12 @@ const api = { }, pet: { - import: (): Promise => ipcRenderer.invoke('pet:import'), - importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + import: (): Promise => invoke('pet:import'), + importPetBundle: (): Promise => invoke('pet:importPetBundle'), read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:read', id, fileName, kind), + invoke('pet:read', id, fileName, kind), delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:delete', id, fileName, kind) + invoke('pet:delete', id, fileName, kind) }, browser: { @@ -2756,10 +2690,10 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + }): Promise => invoke('browser:registerGuest', args), isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => - ipcRenderer.invoke('browser:isGuestRegistered', args), + invoke('browser:isGuestRegistered', args), repairGuestRegistration: (args: { browserPageId: string @@ -2767,10 +2701,10 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), + }): Promise => invoke('browser:repairGuestRegistration', args), unregisterGuest: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:unregisterGuest', args), + invoke('browser:unregisterGuest', args), onWebAuthnAccountRequest: ( callback: (request: BrowserWebAuthnAccountRequest) => void @@ -2793,21 +2727,20 @@ const api = { }, respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => - ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + invoke('browser:respondWebAuthnAccount', response), openDevTools: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:openDevTools', args), + invoke('browser:openDevTools', args), setViewportOverride: (args: { browserPageId: string override: BrowserViewportOverride | null - }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + }): Promise => invoke('browser:setViewportOverride', args), setAnnotationViewportBridge: (args): Promise => - ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), + invoke('browser:setAnnotationViewportBridge', args), - publishClientPageMetadata: (args) => - ipcRenderer.invoke('browser:publishClientPageMetadata', args), + publishClientPageMetadata: (args) => invoke('browser:publishClientPageMetadata', args), onGuestLoadFailed: ( callback: (args: { @@ -2835,7 +2768,7 @@ const api = { return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) }, - proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + proceedCertificate: (args) => invoke('browser:proceedCertificate', args), onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void @@ -3031,30 +2964,30 @@ const api = { }, cancelDownload: (args: { downloadId: string }): Promise => - ipcRenderer.invoke('browser:cancelDownload', args), + invoke('browser:cancelDownload', args), setGrabMode: (args: { browserPageId: string enabled: boolean }): Promise<{ ok: true } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:setGrabMode', args), + invoke('browser:setGrabMode', args), awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => - ipcRenderer.invoke('browser:awaitGrabSelection', args), + invoke('browser:awaitGrabSelection', args), cancelGrab: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:cancelGrab', args), + invoke('browser:cancelGrab', args), captureSelectionScreenshot: (args: { browserPageId: string rect: { x: number; y: number; width: number; height: number } }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + invoke('browser:captureSelectionScreenshot', args), extractHoverPayload: (args: { browserPageId: string }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:extractHoverPayload', args), + invoke('browser:extractHoverPayload', args), onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => @@ -3074,48 +3007,44 @@ const api = { return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) }, - sessionListProfiles: (): Promise => - ipcRenderer.invoke('browser:session:listProfiles'), + sessionListProfiles: (): Promise => invoke('browser:session:listProfiles'), prepareSshWorkspacePartition: (args: { targetId: string browserProfileId?: string skipProbe?: boolean - }): Promise<{ partition: string }> => - ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), + }): Promise<{ partition: string }> => invoke('browser:prepareSshWorkspacePartition', args), sessionCreateProfile: (args: { scope: 'default' | 'isolated' | 'imported' label: string userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + }): Promise => invoke('browser:session:createProfile', args), sessionDeleteProfile: (args: { profileId: string }): Promise => - ipcRenderer.invoke('browser:session:deleteProfile', args), + invoke('browser:session:deleteProfile', args), sessionImportCookies: (args: { profileId: string }): Promise< { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importCookies', args), + > => invoke('browser:session:importCookies', args), sessionResolvePartition: (args: { profileId: string | null }): Promise => - ipcRenderer.invoke('browser:session:resolvePartition', args), + invoke('browser:session:resolvePartition', args), - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsers: (): Promise => invoke('browser:session:detectBrowsers'), sessionDetectBrowsersForClientHost: (args: { environmentId: string - }): Promise => - ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), + }): Promise => invoke('browser:session:detectBrowsersForClientHost', args), sessionImportFromBrowser: (args: { profileId: string browserFamily: string }): Promise< { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importFromBrowser', args), + > => invoke('browser:session:importFromBrowser', args), sessionImportFromBrowserForClientHost: (args: { environmentId: string @@ -3124,18 +3053,18 @@ const api = { browserProfile?: string }): Promise< { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + > => invoke('browser:session:importFromBrowserForClientHost', args), sessionClientRouteImportSources: (args: { environmentId: string }): Promise> => - ipcRenderer.invoke('browser:session:clientRouteImportSources', args), + invoke('browser:session:clientRouteImportSources', args), sessionClearDefaultCookies: (): Promise => - ipcRenderer.invoke('browser:session:clearDefaultCookies'), + invoke('browser:session:clearDefaultCookies'), notifyActiveTabChanged: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:activeTabChanged', args) + invoke('browser:activeTabChanged', args) }, emulator: { @@ -3144,9 +3073,9 @@ const api = { streamKey?: string }): Promise<{ streamId: string - }> => ipcRenderer.invoke('emulator:frameStreamStart', args), + }> => invoke('emulator:frameStreamStart', args), stopFrameStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:frameStreamStop', args), + invoke('emulator:frameStreamStop', args), onFrameStreamFrame: ( callback: (data: { streamId: string; bytes: ArrayBuffer }) => void ): (() => void) => { @@ -3170,9 +3099,9 @@ const api = { startVideoStream: (args: { deviceId: string streamId: string - }): Promise<{ streamId: string }> => ipcRenderer.invoke('emulator:videoStreamStart', args), + }): Promise<{ streamId: string }> => invoke('emulator:videoStreamStart', args), stopVideoStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:videoStreamStop', args), + invoke('emulator:videoStreamStop', args), onVideoStreamMeta: ( callback: (data: { streamId: string @@ -3246,18 +3175,18 @@ const api = { hasHooks: boolean hooks: unknown mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), + }> => invoke('hooks:check', args), inspectSetupScriptImports: (args: { repoId: string hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + }): Promise => invoke('hooks:inspectSetupScriptImports', args), createIssueCommandRunner: (args: { repoId: string worktreePath: string command: string - }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + }): Promise => invoke('hooks:createIssueCommandRunner', args), readIssueCommand: (args: { repoId: string @@ -3269,21 +3198,21 @@ const api = { effectiveContent: string | null localFilePath: string source: 'local' | 'shared' | 'none' - }> => ipcRenderer.invoke('hooks:readIssueCommand', args), + }> => invoke('hooks:readIssueCommand', args), writeIssueCommand: (args: { repoId: string content: string hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) + }): Promise => invoke('hooks:writeIssueCommand', args) }, ephemeralVm: { - listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), - listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), - doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), - provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), - cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), + listRecipes: (args) => invoke('ephemeralVm:listRecipes', args), + listRecipeCatalog: () => invoke('ephemeralVm:listRecipeCatalog'), + doctor: (args) => invoke('ephemeralVm:doctor', args), + provision: (args) => invoke('ephemeralVm:provision', args), + cancelProvision: (args) => invoke('ephemeralVm:cancelProvision', args), onProvisionEvent: (callback) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -3292,26 +3221,26 @@ const api = { ipcRenderer.on('ephemeralVm:provisionEvent', listener) return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) }, - listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), - attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), - suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), - resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), - cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), - stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), - getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) + listRuntimes: () => invoke('ephemeralVm:listRuntimes'), + attachWorkspace: (args) => invoke('ephemeralVm:attachWorkspace', args), + suspendWorkspace: (args) => invoke('ephemeralVm:suspendWorkspace', args), + resumeWorkspace: (args) => invoke('ephemeralVm:resumeWorkspace', args), + cleanup: (args) => invoke('ephemeralVm:cleanup', args), + stopCleanup: (args) => invoke('ephemeralVm:stopCleanup', args), + getCleanupCommand: (args) => invoke('ephemeralVm:getCleanupCommand', args) } satisfies PreloadApi['ephemeralVm'], cache: { - getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), - setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) + getGitHub: () => invoke('cache:getGitHub'), + setGitHub: (args) => invoke('cache:setGitHub', args) } satisfies PreloadApi['cache'], session: { // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. - get: (hostId) => ipcRenderer.invoke('session:get', hostId), - set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), - patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), - flush: () => ipcRenderer.invoke('session:flush'), + get: (hostId) => invoke('session:get', hostId), + set: (args, hostId) => invoke('session:set', args, hostId), + patch: (args, hostId) => invoke('session:patch', args, hostId), + flush: () => invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ @@ -3321,14 +3250,11 @@ const api = { } satisfies PreloadApi['session'], remoteWorkspace: { - get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: () => - ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args) => - ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), + get: (args) => invoke('remoteWorkspace:get', args), + setForConnectedTargets: (args) => invoke('remoteWorkspace:setForConnectedTargets', args), + listEnabledConnectedTargets: () => invoke('remoteWorkspace:listEnabledConnectedTargets'), + listConnectedClients: (args) => invoke('remoteWorkspace:listConnectedClients', args), + clientId: () => invoke('remoteWorkspace:clientId'), onChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => callback(data) @@ -3338,21 +3264,20 @@ const api = { } satisfies PreloadApi['remoteWorkspace'], updater: { - getStatus: () => ipcRenderer.invoke('updater:getStatus'), - getVersion: () => ipcRenderer.invoke('updater:getVersion'), - check: (options) => ipcRenderer.invoke('updater:check', options), - download: () => ipcRenderer.invoke('updater:download'), - dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), - dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), - getLinuxPackageInstallInstructions: () => - ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), - showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), - listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), + getStatus: () => invoke('updater:getStatus'), + getVersion: () => invoke('updater:getVersion'), + check: (options) => invoke('updater:check', options), + download: () => invoke('updater:download'), + dismissNudge: () => invoke('updater:dismissNudge'), + dismissAvailableUpdate: () => invoke('updater:dismissAvailableUpdate'), + getLinuxPackageInstallInstructions: () => invoke('updater:getLinuxPackageInstallInstructions'), + showLinuxPackage: () => invoke('updater:showLinuxPackage'), + listBuilds: (channel) => invoke('updater:listBuilds', channel), quitAndInstall: (): Promise => prepareAndInvokeUpdaterInstall( window, updaterQuitAbortRelay, - () => ipcRenderer.invoke('updater:quitAndInstall'), + () => invoke('updater:quitAndInstall'), awaitBeforeUnloadCheckpoint ), @@ -3370,11 +3295,11 @@ const api = { docPreview: { mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => - ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), + invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), revokeGrant: (grantId: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), authorizeDirectory: (grantId: string, relativePath: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), + invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => callback(payload) @@ -3396,7 +3321,7 @@ const api = { preamble?: string connectionId?: string | null }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => - ipcRenderer.invoke('notebook:runPythonCell', args) + invoke('notebook:runPythonCell', args) }, fs: { @@ -3404,7 +3329,7 @@ const api = { dirPath: string connectionId?: string }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => - ipcRenderer.invoke('fs:readDir', args), + invoke('fs:readDir', args), readFile: (args: { filePath: string connectionId?: string @@ -3415,13 +3340,13 @@ const api = { isImage?: boolean mimeType?: string fileIdentity?: string - }> => ipcRenderer.invoke('fs:readFile', args), + }> => invoke('fs:readFile', args), readLocalLogTail: (args: LocalLogTailReadArgs): Promise => - ipcRenderer.invoke('fs:readLocalLogTail', args), + invoke('fs:readLocalLogTail', args), startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => - ipcRenderer.invoke('fs:startLocalLogTail', args), + invoke('fs:startLocalLogTail', args), stopLocalLogTail: (args: { subscriptionId: string }): Promise => - ipcRenderer.invoke('fs:stopLocalLogTail', args), + invoke('fs:stopLocalLogTail', args), onLocalLogTailChanged: ( callback: (payload: LocalLogTailChangedPayload) => void ): (() => void) => { @@ -3436,77 +3361,76 @@ const api = { filePath: string connectionId: string }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFile', args), + invoke('fs:downloadFile', args), downloadFolder: (args: { dirPath: string connectionId: string }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFolder', args), + invoke('fs:downloadFolder', args), saveDownloadedFile: (args: { suggestedName: string content: string encoding: 'utf8' | 'base64' }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:saveDownloadedFile', args), + invoke('fs:saveDownloadedFile', args), startDownloadedFile: (args: { suggestedName: string }): Promise< { canceled: true } | { canceled: false; transferId: string; destinationPath: string } - > => ipcRenderer.invoke('fs:startDownloadedFile', args), + > => invoke('fs:startDownloadedFile', args), appendDownloadedFileChunk: (args: { transferId: string contentBase64: string - }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), + }): Promise<{ ok: true }> => invoke('fs:appendDownloadedFileChunk', args), finishDownloadedFile: (args: { transferId: string }): Promise<{ canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:finishDownloadedFile', args), + invoke('fs:finishDownloadedFile', args), cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => - ipcRenderer.invoke('fs:cancelDownloadedFile', args), + invoke('fs:cancelDownloadedFile', args), listMarkdownDocuments: (args: { rootPath: string connectionId?: string }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => - ipcRenderer.invoke('fs:listMarkdownDocuments', args), + invoke('fs:listMarkdownDocuments', args), writeFile: ( args: { filePath: string content: string connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:writeFile', args), + ): Promise => invoke('fs:writeFile', args), createFile: ( args: { filePath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createFile', args), + ): Promise => invoke('fs:createFile', args), createDir: ( args: { dirPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createDir', args), + ): Promise => invoke('fs:createDir', args), rename: ( args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:rename', args), + ): Promise => invoke('fs:rename', args), copy: ( args: { sourcePath: string destinationPath: string connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:copy', args), + ): Promise => invoke('fs:copy', args), deletePath: ( args: { targetPath: string connectionId?: string recursive?: boolean } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:deletePath', args), + ): Promise => invoke('fs:deletePath', args), authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), + invoke('fs:authorizeExternalPath', args), stat: (args: { filePath: string connectionId?: string - }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => - ipcRenderer.invoke('fs:stat', args), + }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => invoke('fs:stat', args), pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), + invoke('fs:pathExists', args), listFiles: (args: { rootPath: string connectionId?: string @@ -3514,9 +3438,9 @@ const api = { requestToken?: string maxResults?: number searchQuery?: string - }): Promise => ipcRenderer.invoke('fs:listFiles', args), + }): Promise => invoke('fs:listFiles', args), cancelListFiles: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('fs:cancelListFiles', args), + invoke('fs:cancelListFiles', args), search: (args: { query: string rootPath: string @@ -3527,7 +3451,7 @@ const api = { excludePattern?: string maxResults?: number connectionId?: string - }): Promise => ipcRenderer.invoke('fs:search', args), + }): Promise => invoke('fs:search', args), importExternalPaths: ( args: { sourcePaths: string[] @@ -3555,7 +3479,7 @@ const api = { reason: string } )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), + }> => invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }): Promise<{ @@ -3581,7 +3505,7 @@ const api = { reason: string } )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + }> => invoke('fs:stageExternalPathsForRuntimeUpload', args), resolveDroppedPathsForAgent: ( args: { paths: string[] @@ -3595,11 +3519,11 @@ const api = { reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' }[] failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + }> => invoke('fs:resolveDroppedPathsForAgent', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:watchWorktree', args), + invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:unwatchWorktree', args), + invoke('fs:unwatchWorktree', args), onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => callback(payload) @@ -3617,9 +3541,9 @@ const api = { reuseLineStats?: boolean branchLineTotalMergeBase?: string requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), + }): Promise => invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('git:cancelStatus', args), + invoke('git:cancelStatus', args), setStatusUpstreamRefWatch: (args: { worktreeId: string worktreePath: string @@ -3627,92 +3551,92 @@ const api = { connectionId?: string branch?: string upstreamName?: string - }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), + }): Promise => invoke('git:setStatusUpstreamRefWatch', args), submoduleStatus: (args: { worktreePath: string submodulePath: string connectionId?: string area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + }): Promise => invoke('git:submoduleStatus', args), checkIgnored: (args: { worktreePath: string paths: string[] connectionId?: string - }): Promise => ipcRenderer.invoke('git:checkIgnored', args), + }): Promise => invoke('git:checkIgnored', args), findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => - ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), + invoke('git:findHugeFoldersToIgnore', args), appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => - ipcRenderer.invoke('git:appendGitignore', args), + invoke('git:appendGitignore', args), history: ( args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => ipcRenderer.invoke('git:history', args), + ): Promise => invoke('git:history', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:conflictOperation', args), + invoke('git:conflictOperation', args), abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortMerge', args), + invoke('git:abortMerge', args), abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortRebase', args), + invoke('git:abortRebase', args), diff: (args: { worktreePath: string filePath: string staged: boolean compareAgainstHead?: boolean connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), + }): Promise => invoke('git:diff', args), branchCompare: (args: { worktreePath: string baseRef: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchCompare', args), + }): Promise => invoke('git:branchCompare', args), commitCompare: (args: { worktreePath: string commitId: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), + }): Promise => invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), + }): Promise => invoke('git:upstreamStatus', args), fetch: (args: { worktreePath: string connectionId?: string pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fetch', args), + }): Promise => invoke('git:fetch', args), syncFork: (args: { worktreePath: string connectionId?: string expectedUpstream: GitForkSyncExpectedUpstream - }): Promise => ipcRenderer.invoke('git:syncFork', args), + }): Promise => invoke('git:syncFork', args), push: (args: { worktreePath: string publish?: boolean forceWithLease?: boolean connectionId?: string pushTarget?: unknown - }): Promise => ipcRenderer.invoke('git:push', args), + }): Promise => invoke('git:push', args), pull: (args: { worktreePath: string connectionId?: string pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:pull', args), + }): Promise => invoke('git:pull', args), fastForward: (args: { worktreePath: string connectionId?: string pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fastForward', args), + }): Promise => invoke('git:fastForward', args), rebaseFromBase: (args: { worktreePath: string baseRef: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), + }): Promise => invoke('git:rebaseFromBase', args), branchDiff: (args: { worktreePath: string compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), + }): Promise => invoke('git:branchDiff', args), commitDiff: (args: { worktreePath: string commitOid: string @@ -3720,12 +3644,12 @@ const api = { filePath: string oldPath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), + }): Promise => invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string connectionId?: string - }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + }): Promise<{ success: boolean; error?: string }> => invoke('git:commit', args), generateCommitMessage: (args: { worktreePath: string worktreeId?: string @@ -3734,16 +3658,16 @@ const api = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + }): Promise => invoke('git:generateCommitMessage', args), discoverCommitMessageModels: (args: { agentId: string worktreePath?: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + }): Promise => invoke('git:discoverCommitMessageModels', args), cancelGenerateCommitMessage: (args: { worktreePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + }): Promise => invoke('git:cancelGenerateCommitMessage', args), generatePullRequestFields: (args: { worktreePath: string worktreeId?: string @@ -3758,60 +3682,60 @@ const api = { sourceControlAiResolvedParams?: unknown sourceControlAi?: unknown agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + }): Promise => invoke('git:generatePullRequestFields', args), cancelGeneratePullRequestFields: (args: { worktreePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), + }): Promise => invoke('git:cancelGeneratePullRequestFields', args), stage: (args: { worktreePath: string filePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:stage', args), + }): Promise => invoke('git:stage', args), bulkStage: (args: { worktreePath: string filePaths: string[] connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkStage', args), + }): Promise => invoke('git:bulkStage', args), unstage: (args: { worktreePath: string filePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:unstage', args), + }): Promise => invoke('git:unstage', args), bulkUnstage: (args: { worktreePath: string filePaths: string[] connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), + }): Promise => invoke('git:bulkUnstage', args), discard: (args: { worktreePath: string filePath: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:discard', args), + }): Promise => invoke('git:discard', args), bulkDiscard: (args: { worktreePath: string filePaths: string[] connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), + }): Promise => invoke('git:bulkDiscard', args), remoteFileUrl: (args: { worktreePath: string relativePath: string line: number connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), + }): Promise => invoke('git:remoteFileUrl', args), remoteCommitUrl: (args: { worktreePath: string sha: string connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) + }): Promise => invoke('git:remoteCommitUrl', args) }, ui: { - get: () => ipcRenderer.invoke('ui:get'), - set: (args) => ipcRenderer.invoke('ui:set', args), + get: () => invoke('ui:get'), + set: (args) => invoke('ui:set', args), // Same channel: the local invoke already rejects when main fails to apply. - setWithAck: (args) => ipcRenderer.invoke('ui:set', args), - recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), + setWithAck: (args) => invoke('ui:set', args), + recordFeatureInteraction: (id) => invoke('ui:recordFeatureInteraction', id), onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => callback(ui) @@ -3823,16 +3747,14 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, - consumePendingOpenSettings: (): Promise => - ipcRenderer.invoke('ui:consumePendingOpenSettings'), + consumePendingOpenSettings: (): Promise => invoke('ui:consumePendingOpenSettings'), onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => callback(shareId) ipcRenderer.on('ui:openSkillShare', listener) return () => ipcRenderer.removeListener('ui:openSkillShare', listener) }, - consumePendingSkillShare: (): Promise => - ipcRenderer.invoke('ui:consumePendingSkillShare'), + consumePendingSkillShare: (): Promise => invoke('ui:consumePendingSkillShare'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -4432,21 +4354,20 @@ const api = { return () => ipcRenderer.removeListener('terminal:zoom', listener) }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readText', options), + invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readSelectionText', options), + invoke('clipboard:readSelectionText', options), saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null - }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), - writeClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeText', text), + }): Promise => invoke('clipboard:saveImageAsTempFile', args), + writeClipboardText: (text: string): Promise => invoke('clipboard:writeText', text), writeTerminalClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeTerminalText', text), + invoke('clipboard:writeTerminalText', text), writeSelectionClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeSelectionText', text), + invoke('clipboard:writeSelectionText', text), writeClipboardImage: (dataUrl: string): Promise => - ipcRenderer.invoke('clipboard:writeImage', dataUrl), + invoke('clipboard:writeImage', dataUrl), performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { ipcRenderer.send('ui:performNativePaste', { mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' @@ -4462,7 +4383,7 @@ const api = { connectionId?: string | null } | string - ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), + ): Promise<{ ok: boolean; reason?: string }> => invoke('clipboard:writeFile', args), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), @@ -4517,7 +4438,7 @@ const api = { ipcRenderer.send('window:maximize') }, /** Desktop custom titlebar only: read initial maximize state on mount — maximize-changed only fires on transitions. */ - isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), + isMaximized: (): Promise => invoke('window:isMaximized'), /** Desktop custom titlebar only: subscribe to maximize-state changes so the maximize button shows the right icon. */ onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => @@ -4564,11 +4485,11 @@ const api = { totalPRsCreated: number totalAgentTimeMs: number firstEventAt: number | null - }> => ipcRenderer.invoke('stats:summary') + }> => invoke('stats:summary') }, memory: { - getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') + getSnapshot: (): Promise => invoke('memory:getSnapshot') }, claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), @@ -4577,19 +4498,19 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), + invoke('aiVault:listSessions', args), resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => - ipcRenderer.invoke('aiVault:resolveSessionTitles', args), + invoke('aiVault:resolveSessionTitles', args), cancelListSessions: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('aiVault:cancelListSessions', args), + invoke('aiVault:cancelListSessions', args), prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => - ipcRenderer.invoke('aiVault:prepareSessionResume', args), + invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => - ipcRenderer.invoke('aiVault:listSubagentSessions', args), + invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => - ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + invoke('aiVault:getFirstUserPrompt', args), deleteSession: (args: AiVaultDeleteSessionArgs): Promise => - ipcRenderer.invoke('aiVault:deleteSession', args), + invoke('aiVault:deleteSession', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) @@ -4604,7 +4525,7 @@ const api = { limit?: number, transcriptPath?: string ): Promise => - ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ subscribe: ( args: { @@ -4633,11 +4554,10 @@ const api = { runtime: { syncWindowGraph: ( graph: RuntimeRendererSyncWindowGraph - ): Promise => - ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + ): Promise => invoke('runtime:syncWindowGraph', graph), + getStatus: (): Promise => invoke('runtime:getStatus'), call: (args: { method: string; params?: unknown }): Promise> => - ipcRenderer.invoke('runtime:call', args), + invoke('runtime:call', args), subscribe: async ( args: { method: string; params?: unknown }, callback: (response: RuntimeRpcResponse) => void @@ -4648,7 +4568,7 @@ const api = { callback(response) ipcRenderer.on(channel, listener) try { - await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + await invoke('runtime:subscribe', { subscriptionId, ...args }) } catch (error) { ipcRenderer.removeListener(channel, listener) throw error @@ -4665,27 +4585,27 @@ const api = { }, getTerminalFitOverrides: (): Promise< { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] - > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + > => invoke('runtime:getTerminalFitOverrides'), getTerminalDrivers: (): Promise< { ptyId: string driver: RuntimeTerminalDriverState }[] - > => ipcRenderer.invoke('runtime:getTerminalDrivers'), + > => invoke('runtime:getTerminalDrivers'), getBrowserDrivers: (): Promise< { browserPageId: string driver: RuntimeBrowserDriverState }[] - > => ipcRenderer.invoke('runtime:getBrowserDrivers'), + > => invoke('runtime:getBrowserDrivers'), getBrowserRemoteViewerPages: (): Promise => - ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), + invoke('runtime:getBrowserRemoteViewerPages'), getClientHostedBrowserRows: (): Promise => - ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), + invoke('runtime:getClientHostedBrowserRows'), restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => - ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + invoke('runtime:restoreTerminalFit', { ptyId }), reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => - ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), + invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), onTerminalFitOverrideChanged: ( callback: (event: { ptyId: string @@ -4766,49 +4686,44 @@ const api = { }, runtimeEnvironments: { - list: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:list'), + list: (): Promise => invoke('runtimeEnvironments:list'), addFromPairingCode: (args: { name: string pairingCode: string }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + invoke('runtimeEnvironments:addFromPairingCode', args), verifyAndAddFromPairingCode: (args: { name: string pairingCode: string allowLoopback?: boolean }): Promise => - ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), + invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), resolve: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:resolve', args), + invoke('runtimeEnvironments:resolve', args), remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:remove', args), + invoke('runtimeEnvironments:remove', args), disconnect: (args: { selector: string }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:disconnect', args), + invoke('runtimeEnvironments:disconnect', args), connect: (args: { selector: string timeoutMs?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:connect', args), + }): Promise> => invoke('runtimeEnvironments:connect', args), getStatus: (args: { selector: string timeoutMs?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + }): Promise> => invoke('runtimeEnvironments:getStatus', args), prepareBrowserClientHostPlacement: (args) => - ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), - retryConnectionsNow: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), + invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), + retryConnectionsNow: (): Promise => invoke('runtimeEnvironments:retryConnectionsNow'), call: (args: { selector: string method: string params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:call', args), + }): Promise> => invoke('runtimeEnvironments:call', args), subscribe: async ( args: { selector: string @@ -4828,22 +4743,21 @@ const api = { }, rateLimits: { - get: (): Promise => ipcRenderer.invoke('rateLimits:get'), - refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + get: (): Promise => invoke('rateLimits:get'), + refresh: (): Promise => invoke('rateLimits:refresh'), refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + invoke('rateLimits:refreshCodexForTarget', target), consumeCodexResetCredit: (): Promise => - ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), + invoke('rateLimits:consumeCodexResetCredit'), refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), - setPollingInterval: (ms: number): Promise => - ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + invoke('rateLimits:refreshClaudeForTarget', target), + setPollingInterval: (ms: number): Promise => invoke('rateLimits:setPollingInterval', ms), fetchInactiveClaudeAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + invoke('rateLimits:fetchInactiveClaudeAccounts'), fetchInactiveCodexAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), - refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), - refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => invoke('rateLimits:refreshGrok'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) @@ -4852,69 +4766,66 @@ const api = { }, minimaxCredentials: { - getStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:getStatus'), + getStatus: (): Promise<{ configured: boolean }> => invoke('minimaxCredentials:getStatus'), saveCookie: (cookie: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), - clearCookie: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearCookie') + invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => invoke('minimaxCredentials:clearCookie') }, grokAccounts: { - getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') + getStatus: (): Promise => invoke('grokAccounts:getStatus') }, ssh: { - listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + listTargets: (): Promise => invoke('ssh:listTargets'), listRemovedTargetLabels: (): Promise> => - ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + invoke('ssh:listRemovedTargetLabels'), addTarget: (args: { target: SshTargetCreateInput }): Promise => - ipcRenderer.invoke('ssh:addTarget', args), + invoke('ssh:addTarget', args), updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => - ipcRenderer.invoke('ssh:updateTarget', args), + invoke('ssh:updateTarget', args), - removeTarget: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removeTarget', args), + removeTarget: (args: { id: string }): Promise => invoke('ssh:removeTarget', args), importConfig: (args?: { reAdopt?: boolean }): Promise => - ipcRenderer.invoke('ssh:importConfig', args), + invoke('ssh:importConfig', args), listConfigHosts: (args?: SshConfigHostListArgs): Promise => - ipcRenderer.invoke('ssh:listConfigHosts', args), + invoke('ssh:listConfigHosts', args), resolveConfigHost: (args: { alias: string }): Promise => - ipcRenderer.invoke('ssh:resolveConfigHost', args), + invoke('ssh:resolveConfigHost', args), connect: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + const state: unknown = await invoke('ssh:connect', args) return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null }, - disconnect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:disconnect', args), + disconnect: (args: { targetId: string }): Promise => invoke('ssh:disconnect', args), terminateSessions: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:terminateSessions', args), + invoke('ssh:terminateSessions', args), - resetRelay: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:resetRelay', args), + resetRelay: (args: { targetId: string }): Promise => invoke('ssh:resetRelay', args), getState: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + const state: unknown = await invoke('ssh:getState', args) return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null }, needsPassphrasePrompt: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + invoke('ssh:needsPassphrasePrompt', args), testConnection: async (args: { targetId: string }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { - const result: { success: boolean; error?: string; state?: unknown } = - await ipcRenderer.invoke('ssh:testConnection', args) + const result: { success: boolean; error?: string; state?: unknown } = await invoke( + 'ssh:testConnection', + args + ) const state = result.state ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) : null @@ -4943,7 +4854,7 @@ const api = { remoteHost: string remotePort: number label?: string - }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), + }): Promise => invoke('ssh:addPortForward', args), updatePortForward: (args: { id: string @@ -4952,16 +4863,16 @@ const api = { remoteHost: string remotePort: number label?: string - }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), + }): Promise => invoke('ssh:updatePortForward', args), removePortForward: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removePortForward', args), + invoke('ssh:removePortForward', args), listPortForwards: (args?: { targetId?: string }): Promise => - ipcRenderer.invoke('ssh:listPortForwards', args), + invoke('ssh:listPortForwards', args), listDetectedPorts: async (args: { targetId: string }): Promise => - admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), + admitSshDetectedPorts(await invoke('ssh:listDetectedPorts', args)), onPortForwardsChanged: ( callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void @@ -4992,7 +4903,7 @@ const api = { entries: { name: string; isDirectory: boolean }[] resolvedPath: string pathFlavor: FilesystemPathFlavor - }> => ipcRenderer.invoke('ssh:browseDir', args), + }> => invoke('ssh:browseDir', args), onCredentialRequest: ( callback: (data: { @@ -5023,7 +4934,7 @@ const api = { }, submitCredential: (args: { requestId: string; value: string | null }): Promise => - ipcRenderer.invoke('ssh:submitCredential', args) + invoke('ssh:submitCredential', args) }, // Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), @@ -5032,29 +4943,28 @@ const api = { listExternalManagerForOwner: ( request: ScopedExternalManagerListRequest ): Promise => - ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + invoke('automations:listExternalManagerForOwner', request), listExternalRunsForOwner: ( request: ScopedExternalManagerRunsRequest ): Promise => - ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + invoke('automations:listExternalRunsForOwner', request), createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => - ipcRenderer.invoke('automations:createExternalForOwner', request), + invoke('automations:createExternalForOwner', request), updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => - ipcRenderer.invoke('automations:updateExternalForOwner', request), + invoke('automations:updateExternalForOwner', request), runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => - ipcRenderer.invoke('automations:runExternalActionForOwner', request), + invoke('automations:runExternalActionForOwner', request), retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => - ipcRenderer.invoke('automations:retainExternalScopes', request), + invoke('automations:retainExternalScopes', request), runPrecheck: (args: { automationId: string runId: string - }): Promise => - ipcRenderer.invoke('automations:runPrecheck', args), + }): Promise => invoke('automations:runPrecheck', args), markDispatchResult: (result: AutomationDispatchResult): Promise => - ipcRenderer.invoke('automations:markDispatchResult', result), + invoke('automations:markDispatchResult', result), snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => - ipcRenderer.invoke('automations:snapshotWorkspaceName', args), - rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), + invoke('automations:snapshotWorkspaceName', args), + rendererReady: (): Promise => invoke('automations:rendererReady'), onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => callback(request) @@ -5076,7 +4986,7 @@ const api = { mobile: { listNetworkInterfaces: (): Promise<{ interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] - }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + }> => invoke('mobile:listNetworkInterfaces'), getPairingQR: (args?: { address?: string @@ -5101,14 +5011,14 @@ const api = { deviceId: string connectionMode: MobilePairingConnectionMode } - > => ipcRenderer.invoke('mobile:getPairingQR', args), + > => invoke('mobile:getPairingQR', args), getWindowsFirewallStatus: (args?: { address?: string }) => - ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), + invoke('mobile:getWindowsFirewallStatus', args), - repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), + repairWindowsFirewall: () => invoke('mobile:repairWindowsFirewall'), - openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), + openWindowsNetworkSettings: () => invoke('mobile:openWindowsNetworkSettings'), getRuntimePairingUrl: (args?: { address?: string @@ -5125,25 +5035,24 @@ const api = { endpoint: string deviceId: string } - > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), + > => invoke('mobile:getRuntimePairingUrl', args), listDevices: (): Promise<{ devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] - }> => ipcRenderer.invoke('mobile:listDevices'), + }> => invoke('mobile:listDevices'), revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeDevice', args), + invoke('mobile:revokeDevice', args), - listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), + listRuntimeAccessGrants: () => invoke('mobile:listRuntimeAccessGrants'), revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + invoke('mobile:revokeRuntimeAccess', args), isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady'), + invoke('mobile:isWebSocketReady'), - getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => - ipcRenderer.invoke('mobile:getRelayStatus'), + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => invoke('mobile:getRelayStatus'), onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => @@ -5153,7 +5062,7 @@ const api = { }, consumePendingUnpairedDeviceAuthFailure: (): Promise => - ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), + invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { @@ -5178,12 +5087,11 @@ const api = { return () => ipcRenderer.removeListener('agentStatus:clear', listener) }, /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ - getSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getSnapshot'), + getSnapshot: (): Promise => invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferInterrupt', request), + invoke('agentStatus:inferInterrupt', request), inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), + invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( callback: (entry: MigrationUnsupportedPtyEntry) => void ): (() => void) => { @@ -5217,7 +5125,7 @@ const api = { return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) }, getMigrationUnsupportedSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), + invoke('agentStatus:getMigrationUnsupportedSnapshot'), /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ drop: (paneKey: string): void => { ipcRenderer.send('agentStatus:drop', paneKey) @@ -5245,35 +5153,31 @@ const api = { }, speech: { - getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), - getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), + getCatalog: (): Promise => invoke('speech:getCatalog'), + getModelStates: (): Promise => invoke('speech:getModelStates'), getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), + invoke('speech:getOpenAiApiKeyStatus'), saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), - clearOpenAiApiKey: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:clearOpenAiApiKey'), - downloadModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:downloadModel', modelId), - cancelDownload: (modelId: string): Promise => - ipcRenderer.invoke('speech:cancelDownload', modelId), - deleteModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:deleteModel', modelId), + invoke('speech:saveOpenAiApiKey', apiKey), + clearOpenAiApiKey: (): Promise<{ configured: boolean }> => invoke('speech:clearOpenAiApiKey'), + downloadModel: (modelId: string): Promise => invoke('speech:downloadModel', modelId), + cancelDownload: (modelId: string): Promise => invoke('speech:cancelDownload', modelId), + deleteModel: (modelId: string): Promise => invoke('speech:deleteModel', modelId), startDictation: ( modelId: string, hotwords: string[] | undefined, sessionId: string - ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), + ): Promise => invoke('speech:startDictation', modelId, hotwords, sessionId), feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. - ipcRenderer.invoke( + invoke( 'speech:feedAudio', Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), sampleRate, sessionId ), stopDictation: (sessionId = 'desktop'): Promise => - ipcRenderer.invoke('speech:stopDictation', sessionId), + invoke('speech:stopDictation', sessionId), onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => diff --git a/src/preload/ipc-invoke-boundary-ratchet.test.ts b/src/preload/ipc-invoke-boundary-ratchet.test.ts new file mode 100644 index 00000000000..a27da0e74ae --- /dev/null +++ b/src/preload/ipc-invoke-boundary-ratchet.test.ts @@ -0,0 +1,91 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the envelope chokepoint at the tree level rather than per call site. + * + * `ipcRenderer.invoke` rejects with Electron's envelope, and the renderer's ordinary idiom renders + * `err.message`. That made the leak unfixable per site: the shape that leaks is the shape that is + * correct everywhere else, so a lint rule keyed on it fires on hundreds of sound lines. Routing the + * 731 call sites through one wrapper fixed them at once — this test is what stops the 732nd from + * being written outside it. + * + * The `electronAPI` bridge is covered too, because `contextBridge.exposeInMainWorld('electron', …)` + * hands the renderer a raw `ipcRenderer` whose `invoke` never reaches the wrapper. Nothing uses that + * door today; the point of a ratchet is that it stays shut before something does. + */ +const BOUNDARY_MODULE = 'src/preload/ipc-invoke-boundary.ts' +const SRC_ROOT = resolve(__dirname, '..') +const SCANNED_EXTENSIONS = ['.ts', '.tsx'] +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__' +]) + +/** Whitespace and newlines are legal between the receiver and the call, and one call site used them. */ +const RAW_INVOKE = /ipcRenderer\s*\.\s*invoke\s*\(/ +const RAW_BRIDGE = /window\s*\.\s*electron\s*\.\s*ipcRenderer/ + +/** + * Comments name this shape on purpose — the modules that consume the envelope explain where it + * comes from — so the scan reads code only. A ratchet that fired on prose would be silenced by + * rewording rather than by fixing anything. + */ +function withoutCommentsOrStrings(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1') + .replace(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "''") +} + +/** Tests may reach for the raw call: they are not shipped, and several drive it to prove the wrapper. */ +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectSourceFiles(root: string): string[] { + const found: string[] = [] + for (const entry of readdirSync(root)) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found.push(...collectSourceFiles(full)) + } else if (SCANNED_EXTENSIONS.some((ext) => entry.endsWith(ext)) && !isTestFile(full)) { + found.push(full) + } + } + return found +} + +function offendingModules(pattern: RegExp): string[] { + return collectSourceFiles(SRC_ROOT) + .filter((file) => pattern.test(withoutCommentsOrStrings(readFileSync(file, 'utf8')))) + .map((file) => relative(resolve(SRC_ROOT, '..'), file).replaceAll('\\', '/')) + .sort() +} + +describe('ipcRenderer.invoke stays behind the preload boundary', () => { + it('is called in exactly one module', () => { + expect(offendingModules(RAW_INVOKE)).toEqual([BOUNDARY_MODULE]) + }) + + it('is not reachable through the raw electron bridge either', () => { + expect(offendingModules(RAW_BRIDGE)).toEqual([]) + }) + + /** A scan that matched nothing anywhere would pass both assertions above while enforcing nothing. */ + it('scans the modules it claims to', () => { + const files = collectSourceFiles(SRC_ROOT) + + expect(files.length).toBeGreaterThan(500) + expect(files.some((file) => file.endsWith('preload/index.ts'))).toBe(true) + expect(files.some((file) => file.endsWith('preload/gitlab.ts'))).toBe(true) + }) +}) diff --git a/src/preload/ipc-invoke-boundary.test.ts b/src/preload/ipc-invoke-boundary.test.ts new file mode 100644 index 00000000000..605c5b22cef --- /dev/null +++ b/src/preload/ipc-invoke-boundary.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { invoke: rawInvoke } = vi.hoisted(() => ({ invoke: vi.fn() })) + +vi.mock('electron', () => ({ ipcRenderer: { invoke: rawInvoke } })) + +import { invoke, readableInvokeRejection } from './ipc-invoke-boundary' + +/** The message the renderer would read after the boundary handled this rejection. */ +async function rejectionMessage(thrown: unknown): Promise { + rawInvoke.mockRejectedValueOnce(thrown) + try { + await invoke('workspaces:delete') + throw new Error('expected the boundary to reject') + } catch (error) { + return (error as Error).message + } +} + +describe('preload IPC invoke boundary', () => { + let warn: ReturnType + + beforeEach(() => { + rawInvoke.mockReset() + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + it('resolves untouched, so wrapping costs the success path nothing', async () => { + rawInvoke.mockResolvedValueOnce({ ok: true }) + + await expect(invoke('workspaces:delete', 'w1')).resolves.toEqual({ ok: true }) + expect(rawInvoke).toHaveBeenCalledWith('workspaces:delete', 'w1') + }) + + describe('the shapes an envelope arrives in', () => { + it('strips the renderer wrapper Electron rejects invoke with', async () => { + const message = await rejectionMessage( + new Error( + "Error invoking remote method 'workspaces:delete': Error: Worktree has uncommitted changes" + ) + ) + + expect(message).toBe('Worktree has uncommitted changes') + }) + + it("strips main's handler wrapper", async () => { + const message = await rejectionMessage( + new Error( + "Error occurred in handler for 'workspaces:delete': Error: Worktree has uncommitted changes" + ) + ) + + expect(message).toBe('Worktree has uncommitted changes') + }) + + /** A relay hop re-throws an already-wrapped message inside its own, so the envelope nests. */ + it('strips a relay re-throw that wrapped an already-wrapped message', async () => { + const message = await rejectionMessage( + new Error( + "Error invoking remote method 'pty:connect': Error occurred in handler for 'pty:connect': Error: SSH connection lost, reconnecting" + ) + ) + + expect(message).toBe('SSH connection lost, reconnecting') + }) + }) + + describe('an envelope with nothing behind it', () => { + /** + * The tail is `error.toString()`, so a message-less rejection arrives as a bare class name. + * Narrowing that to '' would render an empty toast — strictly worse than the plumbing — so the + * boundary leaves it for the call site, which has copy naming what it was doing. + */ + it('leaves an empty tail alone rather than rejecting with an empty message', async () => { + const wrapped = "Error invoking remote method 'workspaces:delete': " + + expect(await rejectionMessage(new Error(wrapped))).toBe(wrapped) + }) + + it('leaves an absent tail alone', async () => { + const wrapped = "Error invoking remote method 'workspaces:delete'" + + expect(await rejectionMessage(new Error(wrapped))).toBe(wrapped) + }) + + it('leaves a bare class-name tail alone', async () => { + const wrapped = "Error invoking remote method 'workspaces:delete': Error" + + expect(await rejectionMessage(new Error(wrapped))).toBe(wrapped) + }) + }) + + describe('what the boundary must not destroy', () => { + it('keeps the wrapped form and the stack for diagnostics', async () => { + const thrown = new Error( + "Error invoking remote method 'workspaces:delete': Error: Worktree has uncommitted changes" + ) + const stack = thrown.stack + + rawInvoke.mockRejectedValueOnce(thrown) + await expect(invoke('workspaces:delete')).rejects.toThrow('Worktree has uncommitted changes') + + expect(warn).toHaveBeenCalledWith( + "[ipc] 'workspaces:delete' rejected; raw:", + "Error invoking remote method 'workspaces:delete': Error: Worktree has uncommitted changes", + stack + ) + // V8 fixes `stack` at construction, so the wrapped form survives on the error itself. + expect(thrown.stack).toContain("Error invoking remote method 'workspaces:delete'") + }) + + it('rejects with the same error object, so identity and properties survive', async () => { + const thrown = Object.assign( + new TypeError("Error invoking remote method 'git:push': Error: refusing to push"), + { code: 'EPUSH' } + ) + + rawInvoke.mockRejectedValueOnce(thrown) + const caught = await invoke('git:push').catch((error: unknown) => error) + + expect(caught).toBe(thrown) + expect(caught).toBeInstanceOf(TypeError) + expect((caught as { code: string }).code).toBe('EPUSH') + }) + + it('passes a non-Error rejection through untouched', () => { + expect(readableInvokeRejection('plain string', 'git:push')).toBe('plain string') + expect(readableInvokeRejection(undefined, 'git:push')).toBeUndefined() + }) + + /** A message that never crossed IPC must not be logged as though it had. */ + it('leaves an unwrapped message alone and stays silent', async () => { + expect(await rejectionMessage(new Error('Worktree has uncommitted changes'))).toBe( + 'Worktree has uncommitted changes' + ) + expect(warn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/preload/ipc-invoke-boundary.ts b/src/preload/ipc-invoke-boundary.ts new file mode 100644 index 00000000000..8dc8d67d331 --- /dev/null +++ b/src/preload/ipc-invoke-boundary.ts @@ -0,0 +1,53 @@ +/** + * The one place a renderer-bound IPC rejection is read, so Electron's envelope is removed once. + * + * Electron names a rejected `ipcMain.handle` in the string it rejects with — "Error invoking remote + * method '': " — and the renderer's ordinary idiom is to render `err.message`. That + * idiom is correct everywhere else, so there is no per-call-site rule that separates the leaking + * uses from the rest: the discriminator is whether the value crossed IPC, which is invisible at the + * point it is rendered. Stripping here, where the envelope is created, is what makes the guarantee + * hold for a call site nobody has written yet. + * + * The envelope is not lost, only demoted: the rejection keeps its identity, its properties and its + * stack (V8 fixes `stack` at construction, so it still spells out the wrapped form), and the raw + * message is logged with the channel that produced it before the message is narrowed. + */ + +import { ipcRenderer } from 'electron' +import { stripIpcInvokeEnvelope } from '../shared/ipc-invoke-envelope' + +/** + * The rejection the renderer should see: the same error, carrying only the reason behind it. + * + * Left untouched when the envelope carried no readable reason — a handler that threw a message-less + * error arrives as a bare class name, and an empty message renders as an empty toast, which is a + * worse failure than the plumbing it replaces. Call sites that must never show plumbing already + * branch on that case through `extractIpcErrorMessage`, which supplies copy this layer cannot know. + */ +export function readableInvokeRejection(rejection: unknown, channel: string): unknown { + if (!(rejection instanceof Error)) { + return rejection + } + const wrapped = rejection.message + const reason = stripIpcInvokeEnvelope(wrapped) + if (reason === null || reason === wrapped) { + return rejection + } + console.warn(`[ipc] '${channel}' rejected; raw:`, wrapped, rejection.stack ?? '') + rejection.message = reason + return rejection +} + +/** + * `ipcRenderer.invoke` with the envelope stripped from whatever it rejects with. + * + * `T` is inferred from the binding's declared return type, so this is type-neutral at the 731 call + * sites that adopt it: the preload surface keeps saying what each channel resolves to. + */ +export async function invoke(channel: string, ...args: unknown[]): Promise { + try { + return (await ipcRenderer.invoke(channel, ...args)) as T + } catch (rejection) { + throw readableInvokeRejection(rejection, channel) + } +} diff --git a/src/preload/preload-surface-envelope.test.ts b/src/preload/preload-surface-envelope.test.ts new file mode 100644 index 00000000000..5a9fe931335 --- /dev/null +++ b/src/preload/preload-surface-envelope.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +/** + * The boundary claim measured on the real surface, not on the wrapper in isolation. + * + * Each case picks a binding a leaking call site actually reads — the toast copy quoted in the PR + * came from `ssh.addTarget`, read at `SshPane.tsx:132` — rejects its channel with the envelope Electron produces, and asserts + * the renderer never sees the plumbing. Driving `api` rather than the wrapper is the point: it is + * what proves the 731 bindings are wired to it, not just that the wrapper works when called. + */ +describe('the preload surface strips the envelope for every binding', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + let warn: ReturnType + + beforeEach(() => { + vi.resetModules() + for (const spy of [exposeInMainWorld, invoke, on, removeListener, send, sendSync]) { + spy.mockReset() + } + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + warn.mockRestore() + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + async function loadApi(): Promise { + await import('./index') + return exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + } + + /** The message a renderer call site would put straight into a toast. */ + async function messageFrom(call: Promise): Promise { + return await call.then( + () => { + throw new Error('expected the binding to reject') + }, + (error: unknown) => (error as Error).message + ) + } + + it.each([ + [ + 'ssh.addTarget', + "Error invoking remote method 'ssh:addTarget': Error: Host key verification failed", + 'Host key verification failed' + ], + [ + 'worktrees.remove', + "Error occurred in handler for 'worktrees:remove': Error: Worktree has uncommitted changes", + 'Worktree has uncommitted changes' + ], + [ + 'pty.connect (relay re-throw)', + "Error invoking remote method 'pty:connect': Error occurred in handler for 'pty:connect': Error: SSH connection lost, reconnecting", + 'SSH connection lost, reconnecting' + ] + ])('%s reaches the renderer without the envelope', async (_label, wrapped, expected) => { + invoke.mockRejectedValue(new Error(wrapped)) + const api = await loadApi() + + await expect( + messageFrom(api.ssh.addTarget({ target: {} as never }) as Promise) + ).resolves.toBe(expected) + }) + + it('leaves a reason-less rejection for the call site to name, rather than emptying it', async () => { + const wrapped = "Error invoking remote method 'ssh:addTarget': Error" + invoke.mockRejectedValue(new Error(wrapped)) + const api = await loadApi() + + await expect( + messageFrom(api.ssh.addTarget({ target: {} as never }) as Promise) + ).resolves.toBe(wrapped) + }) + + it('keeps the wrapped form on the log so diagnostics lose nothing', async () => { + const wrapped = + "Error invoking remote method 'ssh:addTarget': Error: Host key verification failed" + invoke.mockRejectedValue(new Error(wrapped)) + const api = await loadApi() + + await messageFrom(api.ssh.addTarget({ target: {} as never }) as Promise) + + expect(warn).toHaveBeenCalledWith( + "[ipc] 'ssh:addTarget' rejected; raw:", + wrapped, + expect.stringContaining("Error invoking remote method 'ssh:addTarget'") + ) + }) + + it('routes the GitLab bindings through the same boundary', async () => { + invoke.mockRejectedValue( + new Error("Error invoking remote method 'gitlab:viewer': Error: 401 Unauthorized") + ) + const api = await loadApi() + + await expect(messageFrom(api.gl.viewer() as Promise)).resolves.toBe('401 Unauthorized') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b08ac4a67f8..6bbb6479e1b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -492,8 +492,9 @@ function TerminalPane( setSessionStateSaveFailureOpen(true) return } - // Why: the surface renders the reason without Electron's IPC envelope, so the wrapped form — - // which names the channel that failed — has to reach the log from here instead. + // Why still here: the preload boundary strips rejections, but a pane error can also arrive over + // an event channel, which never passes through it. This is the wrapped form's only log on that + // path, so it narrowed rather than became dead. if (stripIpcInvokeEnvelope(message) !== message) { console.warn('[terminal] pane error reached the error surface IPC-wrapped; raw:', message) } diff --git a/src/renderer/src/lib/ipc-error-call-site-census.test.ts b/src/renderer/src/lib/ipc-error-call-site-census.test.ts index bf5f2dc5ac6..38782e26747 100644 --- a/src/renderer/src/lib/ipc-error-call-site-census.test.ts +++ b/src/renderer/src/lib/ipc-error-call-site-census.test.ts @@ -124,6 +124,11 @@ const ENVELOPE_MODULE = 'src/shared/ipc-invoke-envelope.ts' * wrong for exactly this reason. Both lists together are the set of modules that open the envelope. */ const DIRECT_STRIPPER_SITES: Readonly> = { + // The boundary itself: not a surface but the source of every message the rest of this list reads. + 'src/preload/ipc-invoke-boundary.ts': { + calls: 1, + surface: 'every preload binding — the rejection the renderer receives' + }, 'src/renderer/src/components/LinuxPackageInstallRecoveryCard.tsx': { calls: 1, surface: 'recovery card' @@ -232,13 +237,17 @@ describe('extractIpcErrorMessage call sites', () => { * discriminator is whether the value crossed IPC, which is not visible where it is rendered. A * lint rule keyed on the idiom would fire on hundreds of correct sites and need suppressions. * - * The narrow fix is the boundary, not the call site. Every envelope in the app is created in one - * place — 730 `ipcRenderer.invoke(` calls live in exactly two files under `src/preload`. A preload - * `invoke` wrapper that rejects with the stripped reason would fix all 118 at once and make this - * census unnecessary, enforced by a ratchet test banning bare `ipcRenderer.invoke` outside it — - * the same shape as the existing `child_process` ratchet. That is a separate change; the cost to - * weigh first is that `TerminalPane.tsx` deliberately keeps the wrapped form in the console, so - * the boundary must strip for display while the log keeps the original. + * The narrow fix is the boundary, not the call site, and it has since been made: all 731 + * `ipcRenderer.invoke(` calls (702 + one written across two lines in `index.ts`, 28 in + * `gitlab.ts`) now go through `src/preload/ipc-invoke-boundary.ts`, which rejects with the + * stripped reason and logs the wrapped form against the channel that produced it. The count above + * is what the boundary closed. `ipc-invoke-boundary-ratchet.test.ts` is what keeps the 732nd call + * from being written outside it. + * + * This file stays as the change-detector it always was. It does not become the proof: it is keyed + * on the stripper, so it still cannot see a site that does not strip — which is now the correct + * state for a call site rather than a leak, because the value reaching it has already been + * narrowed upstream. */ // Why: this is the number the freeze note got wrong, so it is asserted rather than described. it('number 31, and all of them render to a user', () => { From 2e68ea8c6227fe384cfa5d160d13ec6bd0bb4d30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:41:40 -0700 Subject: [PATCH 2/3] test(ipc): close the raw-bridge ratchet's cast-and-alias bypass Arm 2 was anchored on `window`, so a cast between `window` and `.electron` plus an aliased receiver hid a live raw-bridge call from both arms: with such a module in the tree the ratchet passed 3/3. Dropping the anchor reddens arm 2 on that spelling and leaves the clean tree green. Typing the global does not close the door on its own -- `Window.electron` is already declared in src/preload/api-types.ts and the cast spelling still compiles. --- src/preload/ipc-invoke-boundary-ratchet.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/preload/ipc-invoke-boundary-ratchet.test.ts b/src/preload/ipc-invoke-boundary-ratchet.test.ts index a27da0e74ae..bfe5d0fe38f 100644 --- a/src/preload/ipc-invoke-boundary-ratchet.test.ts +++ b/src/preload/ipc-invoke-boundary-ratchet.test.ts @@ -29,7 +29,12 @@ const IGNORED_DIRECTORIES = new Set([ /** Whitespace and newlines are legal between the receiver and the call, and one call site used them. */ const RAW_INVOKE = /ipcRenderer\s*\.\s*invoke\s*\(/ -const RAW_BRIDGE = /window\s*\.\s*electron\s*\.\s*ipcRenderer/ +/** Not anchored on `window`: a cast (`(window as unknown as { electron: … }).electron.ipcRenderer`) + * sits between `window` and `.electron`, and aliasing the receiver hides the call from RAW_INVOKE + * as well — so a `window`-anchored arm 2 passed with a live raw-bridge escape in the tree. Typing + * the global does not close that door: `Window.electron` IS declared (`src/preload/api-types.ts`), + * and the cast spelling still compiles. The lookbehind keeps `electronFoo.ipcRenderer` out. */ +const RAW_BRIDGE = /(? Date: Sat, 29 Aug 2026 15:03:21 -0700 Subject: [PATCH 3/3] test(ipc): measure what crosses the bridge, and make the leak census run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims around the boundary were stronger than the evidence for them. The rethrow was described as preserving identity, properties and stack. It does, inside preload — but the renderer receives the rejection through contextBridge, which copies it. Measured on the real binary under the same webPreferences as createMainWindow: the copy is a plain Error, its prototype and own properties are gone, one object rejected twice arrives as two, and the stack is regenerated from the message. Nothing is lost by narrowing in place that the bridge would not have dropped anyway, because an invoke rejection reaches the boundary already flattened. The comment now says that, and ipc-invoke-boundary-bridge.electron.test.ts asserts the renderer's view. The ratchet had a second bypass: computed access with string-literal keys, which the scan's own string-blanking erased before matching. It passed 3/3 green with a live escape in the tree. Two bypasses is a shape problem, not a spelling problem, so the arms are now labelled for what they are — the preload scan and the new main-world exposure assertion are fences, the raw-bridge scan is a tripwire and says so. window.electron remains a real open door that no source scan can close; the note records that not opening it is the only thing that would. The 115-surface figure could not be reconstructed from the repository. The census now exists and runs: 131 expressions across 84 modules on a stated axis, with a planted leak as a positive control and a stripped variant plus a non-preload variant as negative controls. --- ...pc-invoke-boundary-bridge.electron.test.ts | 281 ++++++++++++++++ .../ipc-invoke-boundary-ratchet.test.ts | 97 +++++- src/preload/ipc-invoke-boundary.ts | 14 +- .../src/lib/ipc-envelope-leak-census.test.ts | 311 ++++++++++++++++++ 4 files changed, 683 insertions(+), 20 deletions(-) create mode 100644 src/preload/ipc-invoke-boundary-bridge.electron.test.ts create mode 100644 src/renderer/src/lib/ipc-envelope-leak-census.test.ts diff --git a/src/preload/ipc-invoke-boundary-bridge.electron.test.ts b/src/preload/ipc-invoke-boundary-bridge.electron.test.ts new file mode 100644 index 00000000000..bd4848ac3b0 --- /dev/null +++ b/src/preload/ipc-invoke-boundary-bridge.electron.test.ts @@ -0,0 +1,281 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { resolveElectronProbeLaunch } from '../main/browser/electron-probe-display-launch' + +/** + * What a renderer consumer actually receives, measured across a real `contextBridge`. + * + * The boundary rethrows the object `ipcRenderer.invoke` rejected with, so inside preload the + * rejection keeps its identity, its properties and its original stack. None of that reaches the + * renderer: `contextBridge` copies the value, and a copy is a fresh plain `Error`. Unit tests that + * stop at the preload side cannot see this, which is why the guarantee was overstated — so this + * file drives the real binary and asserts the renderer's view, including the parts that are lost. + * + * The identity and own-property assertions describe the bridge, not the strip, and would hold with + * the boundary deleted. They are here to keep the *claim* honest, not to pin the fix; the strip is + * pinned by the message and stack assertions, which the `unstripped` control moves. + */ +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +type ErrorView = { + isError: boolean + ctorName: string + name: string + message: string + ownKeys: string[] + stackFirstLine: string + code?: string +} + +type FixtureResult = { + preloadStripped: ErrorView + preloadCarriesOwnProperties: ErrorView + rendererStripped: ErrorView + rendererUnstripped: ErrorView + rendererCarriesOwnProperties: ErrorView + sameObjectAcrossTwoRejections: boolean + mutatingOneCopyLeaksToTheOther: boolean +} + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +/** `sandbox: true` matches `createMainWindow`, and a sandboxed preload may only require `electron`. */ +function preloadEntry(boundaryPath: string): string { + return ` +import { contextBridge, ipcRenderer } from 'electron' +import { invoke } from ${JSON.stringify(boundaryPath)} + +const view = (e) => ({ + isError: e instanceof Error, + ctorName: (e && e.constructor && e.constructor.name) || '', + name: (e && e.name) || '', + message: (e && e.message) || '', + ownKeys: e && typeof e === 'object' ? Object.getOwnPropertyNames(e).sort() : [], + stackFirstLine: e && e.stack ? String(e.stack).split('\\n')[0] : '', + code: e && e.code +}) + +const record = {} + +// The real boundary, on a channel whose handler rejects with a readable reason. +const stripped = invoke('probe:reject').catch((rejection) => { + record.preloadStripped = view(rejection) + throw rejection +}) + +// Control: the same handler reached without the boundary, so the envelope is still on the message. +const unstripped = ipcRenderer.invoke('probe:reject-control') + +// A preload-constructed error carrying own properties, to characterise the bridge itself. +class BoundaryProbeError extends Error { + constructor(message) { + super(message) + this.name = 'BoundaryProbeError' + this.code = 'E_PROBE' + } +} +const carrier = new BoundaryProbeError('carries own properties') +record.preloadCarriesOwnProperties = view(carrier) + +// One object, rejected twice: the renderer sees two copies or one reference. +const shared = new Error('rejected twice') + +contextBridge.exposeInMainWorld('probe', { + stripped: () => stripped, + unstripped: () => unstripped, + carrier: () => Promise.reject(carrier), + sharedFirst: () => Promise.reject(shared), + sharedSecond: () => Promise.reject(shared), + record: () => record +}) +` +} + +const RENDERER_PROBE = ` +const view = (e) => ({ + isError: e instanceof Error, + ctorName: (e && e.constructor && e.constructor.name) || '', + name: (e && e.name) || '', + message: (e && e.message) || '', + ownKeys: e && typeof e === 'object' ? Object.getOwnPropertyNames(e).sort() : [], + stackFirstLine: e && e.stack ? String(e.stack).split('\\n')[0] : '', + code: e && e.code +}) +const rejection = async (call) => { + try { + await call() + } catch (caught) { + return caught + } + throw new Error('expected a rejection') +} +window.__probe = async () => { + const stripped = await rejection(() => window.probe.stripped()) + const unstripped = await rejection(() => window.probe.unstripped()) + const carrier = await rejection(() => window.probe.carrier()) + const first = await rejection(() => window.probe.sharedFirst()) + const second = await rejection(() => window.probe.sharedSecond()) + first.message = 'mutated in the renderer' + return { + ...window.probe.record(), + rendererStripped: view(stripped), + rendererUnstripped: view(unstripped), + rendererCarriesOwnProperties: view(carrier), + sameObjectAcrossTwoRejections: first === second, + mutatingOneCopyLeaksToTheOther: second.message === 'mutated in the renderer' + } +} +` + +function fixtureMain(paths: { htmlPath: string; preloadPath: string; resultPath: string }): string { + return ` +const { app, BrowserWindow, ipcMain } = require('electron') +const { writeFileSync } = require('node:fs') + +class HandlerError extends Error { + constructor(message) { + super(message) + this.name = 'HandlerError' + this.code = 'E_HANDLER' + } +} +const reject = () => { + throw new HandlerError('Host key verification failed') +} +ipcMain.handle('probe:reject', reject) +ipcMain.handle('probe:reject-control', reject) + +const timeout = setTimeout(() => { + writeFileSync(${JSON.stringify(paths.resultPath)}, JSON.stringify({ error: 'fixture timeout' })) + process.exit(1) +}, 30000) + +app.whenReady().then(async () => { + try { + const window = new BrowserWindow({ + show: false, + webPreferences: { + preload: ${JSON.stringify(paths.preloadPath)}, + sandbox: true, + contextIsolation: true, + nodeIntegration: false + } + }) + await window.loadFile(${JSON.stringify(paths.htmlPath)}) + const result = await window.webContents.executeJavaScript('window.__probe()') + clearTimeout(timeout) + writeFileSync(${JSON.stringify(paths.resultPath)}, JSON.stringify(result)) + app.exit(0) + } catch (error) { + clearTimeout(timeout) + writeFileSync( + ${JSON.stringify(paths.resultPath)}, + JSON.stringify({ error: String(error && error.stack ? error.stack : error) }) + ) + app.exit(1) + } +}) +` +} + +async function runFixture(): Promise { + const root = mkdtempSync(join(tmpdir(), 'orca-ipc-boundary-bridge-')) + fixtureRoots.push(root) + const preloadSource = join(root, 'preload-entry.ts') + const htmlPath = join(root, 'index.html') + const mainPath = join(root, 'main.cjs') + const resultPath = join(root, 'result.json') + + writeFileSync(preloadSource, preloadEntry(join(process.cwd(), 'src/preload/ipc-invoke-boundary'))) + writeFileSync(htmlPath, ``) + await buildVite({ + configFile: false, + logLevel: 'silent', + build: { + emptyOutDir: false, + // The fixture asserts on a constructor name, which minification would rewrite. + minify: false, + lib: { + entry: preloadSource, + formats: ['cjs'], + fileName: () => 'preload.cjs', + name: 'OrcaIpcInvokeBoundaryFixture' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron'] } + } + }) + writeFileSync( + mainPath, + fixtureMain({ htmlPath, preloadPath: join(root, 'preload.cjs'), resultPath }) + ) + + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`] + const { executable, args } = resolveElectronProbeLaunch({ + electronBinary, + electronArgs, + platform: process.platform, + display: env.DISPLAY + }) + const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 60_000 }) + const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${rawResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return JSON.parse(rawResult) as FixtureResult +} + +describe('the stripped rejection as a renderer consumer receives it', () => { + it('arrives as an Error carrying the reason, with the envelope only in the preload-side stack', async () => { + const result = await runFixture() + + // What the renderer can rely on. + expect(result.rendererStripped.isError).toBe(true) + expect(result.rendererStripped.message).toBe('Host key verification failed') + + // The control proves the message and stack assertions above move when the strip does not run. + expect(result.rendererUnstripped.message).toBe( + "Error invoking remote method 'probe:reject-control': HandlerError: Host key verification failed" + ) + expect(result.rendererUnstripped.stackFirstLine).toContain('Error invoking remote method') + + // The renderer's stack is regenerated from the message, so it echoes the reason, not the + // envelope. The wrapped form survives on the preload side, which is where it is logged. + expect(result.rendererStripped.stackFirstLine).toBe('Error: Host key verification failed') + expect(result.preloadStripped.stackFirstLine).toContain('Error invoking remote method') + }) + + it('is a copy: prototype, own properties and object identity do not cross the bridge', async () => { + const result = await runFixture() + + // Preload holds a subclass with an own `code`; the renderer receives neither. + expect(result.preloadCarriesOwnProperties.ctorName).toBe('BoundaryProbeError') + expect(result.preloadCarriesOwnProperties.code).toBe('E_PROBE') + expect(result.preloadCarriesOwnProperties.ownKeys).toContain('code') + + expect(result.rendererCarriesOwnProperties.isError).toBe(true) + expect(result.rendererCarriesOwnProperties.ctorName).toBe('Error') + expect(result.rendererCarriesOwnProperties.name).toBe('Error') + expect(result.rendererCarriesOwnProperties.code).toBeUndefined() + expect(result.rendererCarriesOwnProperties.ownKeys).toEqual(['message', 'stack']) + + // One preload object, rejected twice, arrives as two unrelated renderer objects. + expect(result.sameObjectAcrossTwoRejections).toBe(false) + expect(result.mutatingOneCopyLeaksToTheOther).toBe(false) + + // An IPC rejection has nothing else to lose: it reaches the boundary already flattened. + expect(result.preloadStripped.ctorName).toBe('Error') + expect(result.preloadStripped.ownKeys).toEqual(['message', 'stack']) + }) +}) diff --git a/src/preload/ipc-invoke-boundary-ratchet.test.ts b/src/preload/ipc-invoke-boundary-ratchet.test.ts index bfe5d0fe38f..8e783653ae6 100644 --- a/src/preload/ipc-invoke-boundary-ratchet.test.ts +++ b/src/preload/ipc-invoke-boundary-ratchet.test.ts @@ -11,9 +11,33 @@ import { describe, expect, it } from 'vitest' * 731 call sites through one wrapper fixed them at once — this test is what stops the 732nd from * being written outside it. * - * The `electronAPI` bridge is covered too, because `contextBridge.exposeInMainWorld('electron', …)` - * hands the renderer a raw `ipcRenderer` whose `invoke` never reaches the wrapper. Nothing uses that - * door today; the point of a ratchet is that it stays shut before something does. + * ## What each assertion is worth + * + * Two of the three arms below are text scans, and a text scan cannot enumerate the ways JavaScript + * spells a member access. Two separate bypasses have already been demonstrated against this file: + * a cast with an aliased receiver, which a `window`-anchored pattern missed, and a computed access + * whose keys are string literals (`w['electron']['ipcRenderer']['invoke']`), which the scan's own + * string-blanking step erased before matching. The second one passed 3/3 green with a live escape + * in the tree. Patching a third spelling would not change the shape: `'ipc' + 'Renderer'` walks + * past any regex, and so does any key read from a variable. + * + * So the arms are labelled for what they are, not for what would be reassuring: + * + * - `stays behind the boundary` is a **fence**. `ipcRenderer` is only importable in preload, and + * preload is three modules; a text scan is proportionate there and there is nowhere to hide. + * - `the main world gets exactly these globals` is a **fence**, and the load-bearing one. Under + * context isolation `exposeInMainWorld` is the only way to put anything in the renderer's world, + * so the doors are enumerable, they all live in one file, and widening the set is a one-line diff + * in the module a reviewer reads most closely. + * - `is not reached through the raw bridge` is a **tripwire**, and is documented as one. It catches + * somebody reaching for `window.electron.ipcRenderer` without thinking. It does not survive + * somebody who means it, and it must not be read as though it does. + * + * The residue this cannot close: `window.electron` really is a live door to a raw `ipcRenderer`, + * and no scan of renderer source will hold it shut. The only thing that closes it is not opening + * it — nothing in the tree reads `window.electron` today, so the exposure could be dropped. That is + * a change to the app's global surface rather than to this fix, so it is recorded here as the + * standing recommendation and not smuggled in. */ const BOUNDARY_MODULE = 'src/preload/ipc-invoke-boundary.ts' const SRC_ROOT = resolve(__dirname, '..') @@ -29,23 +53,35 @@ const IGNORED_DIRECTORIES = new Set([ /** Whitespace and newlines are legal between the receiver and the call, and one call site used them. */ const RAW_INVOKE = /ipcRenderer\s*\.\s*invoke\s*\(/ -/** Not anchored on `window`: a cast (`(window as unknown as { electron: … }).electron.ipcRenderer`) - * sits between `window` and `.electron`, and aliasing the receiver hides the call from RAW_INVOKE - * as well — so a `window`-anchored arm 2 passed with a live raw-bridge escape in the tree. Typing - * the global does not close that door: `Window.electron` IS declared (`src/preload/api-types.ts`), - * and the cast spelling still compiles. The lookbehind keeps `electronFoo.ipcRenderer` out. */ -const RAW_BRIDGE = /(? pattern.test(withoutCommentsOrStrings(readFileSync(file, 'utf8')))) + .filter((file) => pattern.test(scrub(readFileSync(file, 'utf8')))) .map((file) => relative(resolve(SRC_ROOT, '..'), file).replaceAll('\\', '/')) .sort() } +/** Every name preload hands the renderer: the `exposeInMainWorld` pair and the fallback assignment. */ +function exposedMainWorldGlobals(): string[] { + const source = withoutComments(readFileSync(join(SRC_ROOT, 'preload', 'index.ts'), 'utf8')) + const names = new Set() + for (const [, name] of source.matchAll(/exposeInMainWorld\s*\(\s*['"`]([\w$]+)['"`]/g)) { + names.add(name) + } + for (const [, name] of source.matchAll(/(?:^|\n)\s*window\s*\.\s*([\w$]+)\s*=[^=]/g)) { + names.add(name) + } + return [...names].sort() +} + describe('ipcRenderer.invoke stays behind the preload boundary', () => { it('is called in exactly one module', () => { expect(offendingModules(RAW_INVOKE)).toEqual([BOUNDARY_MODULE]) }) - it('is not reachable through the raw electron bridge either', () => { - expect(offendingModules(RAW_BRIDGE)).toEqual([]) + /** + * A tripwire, not a fence. It reddens on the two spellings that were demonstrated against it and + * on the obvious one; it does not claim to redden on a spelling nobody has written yet. + */ + it('is not reached through the raw electron bridge by any spelling this can see', () => { + expect(offendingModules(RAW_BRIDGE_DOT)).toEqual([]) + expect(offendingModules(RAW_BRIDGE_COMPUTED, withoutComments)).toEqual([]) + }) + + /** + * The arm that actually holds. `exposeInMainWorld` is the only way into an isolated renderer's + * world, every call is in one file, and a new door has to be spelled out here to exist at all. + */ + it('gives the main world exactly these globals, from exactly one module', () => { + expect(exposedMainWorldGlobals()).toEqual(MAIN_WORLD_GLOBALS) + expect(offendingModules(/exposeInMainWorld\s*\(/)).toEqual([PRELOAD_ENTRY]) }) /** A scan that matched nothing anywhere would pass both assertions above while enforcing nothing. */ diff --git a/src/preload/ipc-invoke-boundary.ts b/src/preload/ipc-invoke-boundary.ts index 8dc8d67d331..783ad315f0a 100644 --- a/src/preload/ipc-invoke-boundary.ts +++ b/src/preload/ipc-invoke-boundary.ts @@ -8,9 +8,17 @@ * point it is rendered. Stripping here, where the envelope is created, is what makes the guarantee * hold for a call site nobody has written yet. * - * The envelope is not lost, only demoted: the rejection keeps its identity, its properties and its - * stack (V8 fixes `stack` at construction, so it still spells out the wrapped form), and the raw - * message is logged with the channel that produced it before the message is narrowed. + * The envelope is not lost, only demoted: it is logged here, against the channel that produced it, + * before the message is narrowed. Preload is the last place it can be kept, because this rejection + * does not reach the renderer as this object. `contextBridge` copies what crosses it, so a renderer + * consumer receives a fresh plain `Error` carrying `message` and a `stack` regenerated from that + * message — the prototype, own properties and object identity stop here, and so does the wrapped + * form of the stack. Nothing is lost by narrowing in place that the bridge would not have dropped + * anyway: an `ipcRenderer.invoke` rejection arrives already flattened to `message` and `stack`, its + * own main-process class and properties gone one hop earlier. + * + * Measured across the real binary rather than reasoned about — see + * `ipc-invoke-boundary-bridge.electron.test.ts`, which asserts the renderer's view. */ import { ipcRenderer } from 'electron' diff --git a/src/renderer/src/lib/ipc-envelope-leak-census.test.ts b/src/renderer/src/lib/ipc-envelope-leak-census.test.ts new file mode 100644 index 00000000000..92e2d6f125f --- /dev/null +++ b/src/renderer/src/lib/ipc-envelope-leak-census.test.ts @@ -0,0 +1,311 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The leaking population, computed rather than quoted. + * + * The case for the boundary rests on a number, and the number had only ever been written down: the + * enumeration that produced it did not survive into the repository, so the figure in the PR body + * could not be checked by anyone reading it. Three independent attempts on this population reported + * 115, 118 and 137, which is what an unreproducible census looks like from outside. This file is the + * census itself, so the figure is whatever running it says. + * + * ## The axis + * + * One axis, stated so a disagreeing count can be attributed instead of argued: **renderer + * expressions that pass a rejection's free text into a render sink, in a module that talks to the + * preload surface**. Concretely, an expression is counted when all of these hold: + * + * - it is an argument to a `toast.*(…)` call or to a `set(…)` state setter; + * - it reads free text off a binding introduced by `catch (…)` or `.catch(…)` in the same module — + * `binding.message`, `String(binding)` or `${binding}`; + * - that argument does not route through `extractIpcErrorMessage` or `stripIpcInvokeEnvelope`; + * - the module contains a `window.api.*` call, so a rejection reaching it can have crossed IPC. + * + * ## What it cannot see + * + * Stated rather than implied, because this population has been undercounted repeatedly. It is regex + * over source, not dataflow, so it is a floor and not a total: + * + * - text laundered through an intermediate variable, a helper in another module, or a store action + * not named `set*`, is invisible to it; + * - its sinks are `toast.*` and `set*` only — direct JSX rendering of `{err.message}`, error + * boundaries and `alert` are not counted; + * - it cannot see event-channel payloads: `ipcRenderer.on` is not `invoke`, so a main-process string + * arriving over an event is outside both this census and the fix; + * - the `window.api.*` test is module-level, so a module that both calls the preload surface and + * catches something else contributes a false positive, and a module that receives its rejection + * from a caller contributes a false negative. + * + * ## Before and after + * + * The "before" is what this file computes. The "after" is not a second scan and cannot be: the fix + * is upstream of every expression counted here, so the source is textually identical either side of + * it and re-running the census would report the same number. What changes is the value that arrives. + * The after-column is carried by two other running tests, and this file is only honest alongside + * them: `ipc-invoke-boundary-bridge.electron.test.ts` shows a renderer consumer receiving the + * narrowed reason across a real `contextBridge`, and `ipc-invoke-boundary-ratchet.test.ts` shows + * that the wrapper is the only path to `ipcRenderer.invoke`, which is what makes that observation + * general rather than anecdotal. + * + * To regenerate `LEAKING_EXPRESSIONS` after a legitimate change, run this file: the failure prints + * the current population as a diff against the recorded one. + */ +const REPO_ROOT = resolve(__dirname, '../../../..') +const RENDERER_ROOT = join(REPO_ROOT, 'src/renderer/src') +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__' +]) + +/** Every leaking expression, by module. Computed by this file; not transcribed from anywhere. */ +const LEAKING_EXPRESSIONS: Readonly> = { + 'src/renderer/src/app-shell/use-app-session-persistence.ts': 1, + 'src/renderer/src/components/GitLabItemDialog.tsx': 2, + 'src/renderer/src/components/LinearItemDrawer.tsx': 1, + 'src/renderer/src/components/NewWorkspaceComposerCard.tsx': 1, + 'src/renderer/src/components/Terminal.tsx': 1, + 'src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx': 1, + 'src/renderer/src/components/editor/useIpynbCellExecution.ts': 1, + 'src/renderer/src/components/emulator-pane/use-mobile-emulator-agent-setup-state.ts': 2, + 'src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx': 1, + 'src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts': 2, + 'src/renderer/src/components/github-item-dialog/land-pull-request/pr-actions-panel.tsx': 1, + 'src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx': 1, + 'src/renderer/src/components/jira-connect-dialog.tsx': 1, + 'src/renderer/src/components/linear-api-key-dialog.tsx': 1, + 'src/renderer/src/components/new-workspace/pick-local-project-folder.ts': 1, + 'src/renderer/src/components/onboarding/ThemeStep.tsx': 1, + 'src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts': 2, + 'src/renderer/src/components/pull-request-page/actions/merge-actions.ts': 3, + 'src/renderer/src/components/pull-request-page/checks/refresh.ts': 1, + 'src/renderer/src/components/pull-request-page/checks/rerun.ts': 1, + 'src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts': 1, + 'src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts': 1, + 'src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx': 1, + 'src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts': 1, + 'src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-creation.ts': 1, + 'src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts': 1, + 'src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts': 2, + 'src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts': 1, + 'src/renderer/src/components/settings/AgentSkillSetupPanel.tsx': 1, + 'src/renderer/src/components/settings/BrowserUseExamples.tsx': 1, + 'src/renderer/src/components/settings/BrowserUsePane.tsx': 1, + 'src/renderer/src/components/settings/CliSection.tsx': 3, + 'src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx': 1, + 'src/renderer/src/components/settings/ComputerUsePane.tsx': 3, + 'src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx': 4, + 'src/renderer/src/components/settings/EphemeralVmsPane.tsx': 1, + 'src/renderer/src/components/settings/GrokAccountsSection.tsx': 1, + 'src/renderer/src/components/settings/KeybindingsFileActions.tsx': 2, + 'src/renderer/src/components/settings/ManageSessionsSection.tsx': 2, + 'src/renderer/src/components/settings/MobileEmulatorAvailabilityDetails.tsx': 2, + 'src/renderer/src/components/settings/MobileEmulatorExamples.tsx': 1, + 'src/renderer/src/components/settings/OrchestrationSkillPromptDialog.tsx': 1, + 'src/renderer/src/components/settings/RepositoryIconTabs.tsx': 1, + 'src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx': 4, + 'src/renderer/src/components/settings/SkillUsageExampleDialog.tsx': 1, + 'src/renderer/src/components/settings/SshPane.tsx': 8, + 'src/renderer/src/components/settings/SshPassphraseDialog.tsx': 2, + 'src/renderer/src/components/settings/VoicePane.tsx': 2, + 'src/renderer/src/components/settings/WslCliRegistration.tsx': 3, + 'src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx': 1, + 'src/renderer/src/components/settings/bitbucket-integration-card.tsx': 1, + 'src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx': 1, + 'src/renderer/src/components/shared/useDaemonActions.tsx': 2, + 'src/renderer/src/components/sidebar/AddRemoteHostDialog.tsx': 2, + 'src/renderer/src/components/sidebar/AddRepoSteps.tsx': 1, + 'src/renderer/src/components/sidebar/ForgetSshWorkspaceDialog.tsx': 2, + 'src/renderer/src/components/sidebar/HostRemoveDialog.tsx': 1, + 'src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx': 2, + 'src/renderer/src/components/sidebar/NonGitFolderDialog.tsx': 1, + 'src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx': 1, + 'src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx': 1, + 'src/renderer/src/components/sidebar/use-add-repo-host-selection.ts': 2, + 'src/renderer/src/components/sidebar/useSidebarProjectDrop.ts': 1, + 'src/renderer/src/components/status-bar/SshStatusSegment.tsx': 1, + 'src/renderer/src/components/status-bar/SshTargetStatusRow.tsx': 2, + 'src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx': 1, + 'src/renderer/src/components/task-page/hooks/use-task-page-create-github-submit.ts': 1, + 'src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx': 1, + 'src/renderer/src/hooks/composer-state/attachment-drop-state.ts': 1, + 'src/renderer/src/hooks/composer-state/gitlab-provider-selection.ts': 1, + 'src/renderer/src/hooks/composer-state/host-runtime-effects.ts': 2, + 'src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts': 4, + 'src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts': 1, + 'src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts': 1, + 'src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts': 1, + 'src/renderer/src/lib/agent-skill-cli-prerequisite.ts': 1, + 'src/renderer/src/lib/http-link-routing.ts': 1, + 'src/renderer/src/lib/launch-work-item-direct.ts': 1, + 'src/renderer/src/lib/sidebar-worktree-activation.ts': 1, + 'src/renderer/src/store/project-groups/nested-repository-operations.ts': 1, + 'src/renderer/src/store/repos/repo-removal.ts': 1, + 'src/renderer/src/store/slices/orca-profiles-auth-actions.ts': 5, + 'src/renderer/src/store/slices/orca-profiles.ts': 3, + 'src/renderer/src/store/slices/settings.ts': 1 +} + +type Module = { path: string; source: string } + +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectModules(root: string): Module[] { + const found: Module[] = [] + for (const entry of readdirSync(root)) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found.push(...collectModules(full)) + } else if ((entry.endsWith('.ts') || entry.endsWith('.tsx')) && !isTestFile(full)) { + found.push({ + path: relative(REPO_ROOT, full).replaceAll('\\', '/'), + source: readFileSync(full, 'utf8') + }) + } + } + return found +} + +/** Strings go too: their contents are prose, and their parentheses would break argument balancing. */ +function withoutCommentsOrStringBodies(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1') + .replace(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/g, '""') + .replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '""') +} + +/** Bindings a rejection can arrive on: `catch (e)` and the `.catch(e => …)` callback parameter. */ +function rejectionBindings(source: string): string[] { + const names = new Set() + for (const [, name] of source.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)/g)) { + names.add(name) + } + for (const [, name] of source.matchAll(/\.catch\s*\(\s*(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g)) { + names.add(name) + } + names.delete('function') + names.delete('async') + return [...names] +} + +function balancedArgument(source: string, openParenIndex: number): string { + let depth = 0 + for (let index = openParenIndex; index < source.length; index += 1) { + if (source[index] === '(') { + depth += 1 + } else if (source[index] === ')') { + depth -= 1 + if (depth === 0) { + return source.slice(openParenIndex + 1, index) + } + } + } + return source.slice(openParenIndex + 1) +} + +const SINK = /\btoast\s*(?:\.\s*[\w$]+)?\s*\(|\bset[A-Z][\w$]*\s*\(/g +const ALREADY_STRIPPED = /extractIpcErrorMessage\s*\(|stripIpcInvokeEnvelope/ +const REACHES_PRELOAD = /\bwindow\s*\.\s*api\s*\./ + +export function censusLeakingExpressions(modules: readonly Module[]): Record { + const leaking: Record = {} + for (const { path, source } of modules) { + const cleaned = withoutCommentsOrStringBodies(source) + if (!REACHES_PRELOAD.test(cleaned)) { + continue + } + const bindings = rejectionBindings(cleaned) + if (bindings.length === 0) { + continue + } + const alternation = bindings.join('|') + const readsFreeText = new RegExp( + `\\b(?:${alternation})\\b\\s*\\.\\s*message\\b` + + `|String\\(\\s*(?:${alternation})\\s*\\)` + + `|\\$\\{\\s*(?:${alternation})\\s*\\}` + ) + SINK.lastIndex = 0 + while (SINK.exec(cleaned) !== null) { + const argument = balancedArgument(cleaned, SINK.lastIndex - 1) + if (readsFreeText.test(argument) && !ALREADY_STRIPPED.test(argument)) { + leaking[path] = (leaking[path] ?? 0) + 1 + } + } + } + return leaking +} + +/** + * A module written to leak, injected rather than written to disk so the controls cannot leave the + * tree mutated if the run is interrupted. + */ +const PLANTED_LEAK: Module = { + path: 'src/renderer/src/planted-control.ts', + source: ` + import { toast } from 'sonner' + export async function planted(): Promise { + try { + await window.api.ssh.addTarget() + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + } + } + ` +} + +describe('the renderer expressions that would render an IPC envelope', () => { + const treeModules = collectModules(RENDERER_ROOT) + + it('are these, at these counts', () => { + expect(censusLeakingExpressions(treeModules)).toEqual(LEAKING_EXPRESSIONS) + }) + + it('total 131 expressions across 84 modules', () => { + const census = censusLeakingExpressions(treeModules) + const total = Object.values(census).reduce((sum, count) => sum + count, 0) + + expect(total).toBe(131) + expect(Object.keys(census)).toHaveLength(84) + }) + + /** + * The control the previous census never had: a census nobody has shown can detect the thing is + * not evidence that the thing is absent. + */ + it('detects a planted leak, and counts exactly the one', () => { + const before = censusLeakingExpressions(treeModules) + const after = censusLeakingExpressions([...treeModules, PLANTED_LEAK]) + + expect(after[PLANTED_LEAK.path]).toBe(1) + expect(Object.keys(after)).toHaveLength(Object.keys(before).length + 1) + }) + + /** The other half of the control: the detector has to be able to say no, or it says nothing. */ + it('does not count the same expression once it is stripped, or outside the preload surface', () => { + const stripped: Module = { + path: PLANTED_LEAK.path, + source: PLANTED_LEAK.source.replace( + 'err instanceof Error ? err.message : String(err)', + 'extractIpcErrorMessage(err)' + ) + } + const noPreloadCall: Module = { + path: PLANTED_LEAK.path, + source: PLANTED_LEAK.source.replace('window.api.ssh.addTarget()', 'somethingLocal()') + } + + expect(censusLeakingExpressions([stripped])).toEqual({}) + expect(censusLeakingExpressions([noPreloadCall])).toEqual({}) + }) +})