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({}) + }) +})