fix(sandbox): migrate caCertificate/clientCertificate off the services.invoke gateway (Phase 1a)

Registers a named services.<serviceName>.<methodName> IPC handler for each of the 10
caCertificate/clientCertificate pairs instead of routing them through the generic
reflection-based services.invoke dispatcher. The renderer proxy (preload bridge and
the non-isolated fallback) now consults a shared migrated-pairs list to pick the named
channel for these pairs while everything else keeps using the legacy gateway, with a
test cross-checking the list against what main/ipc actually registers so the two can't
drift apart. A parity test proves each named handler forwards the same args to the same
services.* call the old dispatch made.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kyle
2026-07-26 17:54:10 -04:00
parent 71086daf62
commit 6235935da1
9 changed files with 156 additions and 15 deletions

View File

@@ -13,6 +13,7 @@ import type { GenerateMcpSamplingResponseFunction } from '~/common/plugins/types
import type { RenderedRequest } from '~/common/templating/types';
import { invariant } from '~/common/utils/invariant';
import { invokeWithNormalizedError } from '~/main/ipc/invoke';
import { resolveServicesInvokeChannel } from '~/main/ipc/migrated-services-invoke-pairs';
import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service';
import type { PluginInvokeMethod } from '~/plugins/invoke-method';
import { isUserAbortResolveMergeConflictError, UserAbortResolveMergeConflictError } from '~/sync/vcs/errors';
@@ -552,8 +553,12 @@ if (process.contextIsolated) {
// function and rebuild the services Proxy in the isolated renderer world.
contextBridge.exposeInMainWorld(
'_dataServicesInvoke',
(serviceName: string, methodName: string, ...args: unknown[]) =>
invokeWithNormalizedError('services.invoke', serviceName, methodName, ...args),
(serviceName: string, methodName: string, ...args: unknown[]) => {
const channel = resolveServicesInvokeChannel(serviceName, methodName);
return channel === 'services.invoke'
? invokeWithNormalizedError('services.invoke', serviceName, methodName, ...args)
: invokeWithNormalizedError(channel, ...args);
},
);
contextBridge.exposeInMainWorld('env', env);
} else {

View File

@@ -6,16 +6,16 @@ exports[`describeServicesInvokeSurface (real repo) > matches the current service
"apiSpec.getOrCreateForParentId: services.invoke (1 call site)",
"apiSpec.update: services.invoke (1 call site)",
"apiSpec.updateOrCreateForParentId: services.invoke (1 call site)",
"caCertificate.create: services.invoke (1 call site)",
"caCertificate.getById: services.invoke (1 call site)",
"caCertificate.getByParentId: services.invoke (4 call sites)",
"caCertificate.removeWhere: services.invoke (1 call site)",
"caCertificate.update: services.invoke (1 call site)",
"clientCertificate.create: services.invoke (1 call site)",
"clientCertificate.findByParentId: services.invoke (5 call sites)",
"clientCertificate.getById: services.invoke (2 call sites)",
"clientCertificate.remove: services.invoke (1 call site)",
"clientCertificate.update: services.invoke (1 call site)",
"caCertificate.create: named handler (1 call site)",
"caCertificate.getById: named handler (1 call site)",
"caCertificate.getByParentId: named handler (4 call sites)",
"caCertificate.removeWhere: named handler (1 call site)",
"caCertificate.update: named handler (1 call site)",
"clientCertificate.create: named handler (1 call site)",
"clientCertificate.findByParentId: named handler (5 call sites)",
"clientCertificate.getById: named handler (2 call sites)",
"clientCertificate.remove: named handler (1 call site)",
"clientCertificate.update: named handler (1 call site)",
"cloudCredential.all: services.invoke (3 call sites)",
"cloudCredential.create: services.invoke (1 call site)",
"cloudCredential.getById: services.invoke (4 call sites)",

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import * as handlers from '../services-invoke-migrated-handlers';
vi.mock('insomnia-data', () => ({
services: {
caCertificate: { create: vi.fn(), getById: vi.fn(), getByParentId: vi.fn(), removeWhere: vi.fn(), update: vi.fn() },
clientCertificate: { create: vi.fn(), findByParentId: vi.fn(), getById: vi.fn(), remove: vi.fn(), update: vi.fn() },
},
}));
// Reference behavior: exactly what the old services.invoke reflection dispatch did for a
// (serviceName, methodName) pair — `services[serviceName][methodName](...args)`. Every migrated
// named handler below is checked against this, not just against its own naming convention.
const legacyDispatch = async (serviceName: string, methodName: string, ...args: unknown[]) => {
const { services } = await import('insomnia-data');
const service = (services as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[serviceName];
return service[methodName](...args);
};
const CASES: { handlerName: keyof typeof handlers; serviceName: string; methodName: string; args: unknown[] }[] = [
{ handlerName: 'caCertificateCreate', serviceName: 'caCertificate', methodName: 'create', args: [{ parentId: 'w1' }] },
{ handlerName: 'caCertificateGetById', serviceName: 'caCertificate', methodName: 'getById', args: ['cert1'] },
{ handlerName: 'caCertificateGetByParentId', serviceName: 'caCertificate', methodName: 'getByParentId', args: ['w1'] },
{ handlerName: 'caCertificateRemoveWhere', serviceName: 'caCertificate', methodName: 'removeWhere', args: ['w1'] },
{ handlerName: 'caCertificateUpdate', serviceName: 'caCertificate', methodName: 'update', args: [{ _id: 'cert1' }, { path: '/new.pem' }] },
{ handlerName: 'clientCertificateCreate', serviceName: 'clientCertificate', methodName: 'create', args: [{ parentId: 'w1' }] },
{ handlerName: 'clientCertificateFindByParentId', serviceName: 'clientCertificate', methodName: 'findByParentId', args: ['w1'] },
{ handlerName: 'clientCertificateGetById', serviceName: 'clientCertificate', methodName: 'getById', args: ['cert1'] },
{ handlerName: 'clientCertificateRemove', serviceName: 'clientCertificate', methodName: 'remove', args: [{ _id: 'cert1' }] },
{ handlerName: 'clientCertificateUpdate', serviceName: 'clientCertificate', methodName: 'update', args: [{ _id: 'cert1' }, { host: 'localhost' }] },
];
describe.each(CASES)('$handlerName', ({ handlerName, serviceName, methodName, args }) => {
it('forwards to the same services.* call, with the same args and result, as the old services.invoke dispatch', async () => {
const { services } = await import('insomnia-data');
const service = (services as unknown as Record<string, Record<string, ReturnType<typeof vi.fn>>>)[serviceName];
service[methodName].mockReset().mockResolvedValue({ sentinel: `${handlerName}-result` });
const legacyResult = await legacyDispatch(serviceName, methodName, ...args);
const namedHandlerResult = await (handlers[handlerName] as (...a: unknown[]) => unknown)({}, ...args);
expect(namedHandlerResult).toEqual(legacyResult);
expect(service[methodName]).toHaveBeenCalledWith(...args);
});
});

View File

@@ -4,6 +4,7 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MIGRATED_SERVICES_INVOKE_PAIRS } from '../migrated-services-invoke-pairs';
import {
describeServicesInvokeSurface,
findPairsMissingNamedHandler,
@@ -96,4 +97,14 @@ describe('describeServicesInvokeSurface (real repo)', () => {
expect(entries.length).toBeGreaterThan(0);
expect(formatServicesInvokeSurfaceEntries(entries)).toMatchSnapshot();
});
// Keeps the renderer-transport allowlist (migrated-services-invoke-pairs.ts) from drifting out of
// sync with what main/ipc/*.ts actually registers: a pair the proxy would route to a named channel
// that doesn't exist would hard-fail every call; a pair with a named handler the proxy doesn't know
// about would silently keep using the generic gateway.
it('has a named handler for every pair the renderer proxy is told to route there, and vice versa', () => {
const entries = describeServicesInvokeSurface();
const detectedMigrated = new Set(entries.filter(e => e.hasNamedHandler).map(e => e.pair));
expect(detectedMigrated).toEqual(new Set(MIGRATED_SERVICES_INVOKE_PAIRS));
});
});

View File

@@ -43,6 +43,16 @@ export type HandleChannels =
| 'curlRequest'
| 'database.caCertificate.create'
| 'services.invoke'
| 'services.caCertificate.create'
| 'services.caCertificate.getById'
| 'services.caCertificate.getByParentId'
| 'services.caCertificate.removeWhere'
| 'services.caCertificate.update'
| 'services.clientCertificate.create'
| 'services.clientCertificate.findByParentId'
| 'services.clientCertificate.getById'
| 'services.clientCertificate.remove'
| 'services.clientCertificate.update'
| 'extractJsonFileFromPostmanDataDumpArchive'
| 'generateCommitsFromDiff'
| 'generateMockRouteDataFromSpec'

View File

@@ -44,6 +44,18 @@ import {
exportWorkspacesHAR,
} from '~/main/har';
import { convert } from '~/main/importers/convert';
import {
caCertificateCreate,
caCertificateGetById,
caCertificateGetByParentId,
caCertificateRemoveWhere,
caCertificateUpdate,
clientCertificateCreate,
clientCertificateFindByParentId,
clientCertificateGetById,
clientCertificateRemove,
clientCertificateUpdate,
} from '~/main/ipc/services-invoke-migrated-handlers';
import { getCurrentConfig, type LLMConfigServiceAPI } from '~/main/llm-config-service';
import { multipartBufferToArray, type Part } from '~/main/multipart-buffer-to-array';
import { insecureReadFile, insecureReadFileWithEncoding, isPathAllowed, secureReadFile } from '~/main/secure-read-file';
@@ -396,6 +408,16 @@ export function registerMainHandlers() {
ipcMainHandle('database.caCertificate.create', async (_, options: { parentId: string; path: string }) => {
return services.caCertificate.create(options);
});
ipcMainHandle('services.caCertificate.create', caCertificateCreate);
ipcMainHandle('services.caCertificate.getById', caCertificateGetById);
ipcMainHandle('services.caCertificate.getByParentId', caCertificateGetByParentId);
ipcMainHandle('services.caCertificate.removeWhere', caCertificateRemoveWhere);
ipcMainHandle('services.caCertificate.update', caCertificateUpdate);
ipcMainHandle('services.clientCertificate.create', clientCertificateCreate);
ipcMainHandle('services.clientCertificate.findByParentId', clientCertificateFindByParentId);
ipcMainHandle('services.clientCertificate.getById', clientCertificateGetById);
ipcMainHandle('services.clientCertificate.remove', clientCertificateRemove);
ipcMainHandle('services.clientCertificate.update', clientCertificateUpdate);
ipcMainHandle('createPlugin', async (_, options: { pluginName: string; mainJs: string }) => {
return createPlugin(options.pluginName, options.mainJs);
});

View File

@@ -0,0 +1,24 @@
// Single source of truth for which (serviceName, methodName) pairs have their own
// `services.<serviceName>.<methodName>` IPC handler instead of routing through the generic
// `services.invoke` gateway (see services-invoke-surface.ts, SERVICES-INVOKE-MIGRATION-PLAN.md).
// Consulted by both the preload bridge and the non-isolated renderer proxy fallback so the two
// transports can never pick a different channel for the same pair. No Node-only imports here — this
// module is bundled into the renderer as well as the main process.
export const MIGRATED_SERVICES_INVOKE_PAIRS: ReadonlySet<string> = new Set<string>([
'caCertificate.create',
'caCertificate.getById',
'caCertificate.getByParentId',
'caCertificate.removeWhere',
'caCertificate.update',
'clientCertificate.create',
'clientCertificate.findByParentId',
'clientCertificate.getById',
'clientCertificate.remove',
'clientCertificate.update',
]);
/** The IPC channel a `services.<serviceName>.<methodName>` call should use: the named channel once migrated, else the legacy generic gateway. */
export const resolveServicesInvokeChannel = (serviceName: string, methodName: string): string =>
MIGRATED_SERVICES_INVOKE_PAIRS.has(`${serviceName}.${methodName}`)
? `services.${serviceName}.${methodName}`
: 'services.invoke';

View File

@@ -0,0 +1,19 @@
import type { CaCertificate, ClientCertificate } from 'insomnia-data';
import { services } from 'insomnia-data';
// Named per-pair handlers for services.invoke pairs migrated off the generic reflection-based
// gateway (see services-invoke-surface.ts, SERVICES-INVOKE-MIGRATION-PLAN.md). Each forwards to the
// exact same services.* call the generic dispatch made for that pair, with the same arguments — main.ts
// registers each of these under the literal channel name `services.<serviceName>.<methodName>`.
export const caCertificateCreate = (_: unknown, patch: Partial<CaCertificate> = {}) => services.caCertificate.create(patch);
export const caCertificateGetById = (_: unknown, id: string) => services.caCertificate.getById(id);
export const caCertificateGetByParentId = (_: unknown, parentId: string) => services.caCertificate.getByParentId(parentId);
export const caCertificateRemoveWhere = (_: unknown, parentId: string) => services.caCertificate.removeWhere(parentId);
export const caCertificateUpdate = (_: unknown, cert: CaCertificate, patch: Partial<CaCertificate> = {}) => services.caCertificate.update(cert, patch);
export const clientCertificateCreate = (_: unknown, patch: Partial<ClientCertificate> = {}) => services.clientCertificate.create(patch);
export const clientCertificateFindByParentId = (_: unknown, parentId: string) => services.clientCertificate.findByParentId(parentId);
export const clientCertificateGetById = (_: unknown, id: string) => services.clientCertificate.getById(id);
export const clientCertificateRemove = (_: unknown, cert: ClientCertificate) => services.clientCertificate.remove(cert);
export const clientCertificateUpdate = (_: unknown, cert: ClientCertificate, patch: Partial<ClientCertificate> = {}) => services.clientCertificate.update(cert, patch);

View File

@@ -1,6 +1,10 @@
import { invokeWithNormalizedError } from '~/main/ipc/invoke';
import { resolveServicesInvokeChannel } from '~/main/ipc/migrated-services-invoke-pairs';
import { createServicesProxy } from '~/ui/services-proxy';
export const servicesProxy = createServicesProxy((serviceName, methodName, ...args) =>
invokeWithNormalizedError<unknown>('services.invoke', serviceName, methodName, ...args),
);
export const servicesProxy = createServicesProxy((serviceName, methodName, ...args) => {
const channel = resolveServicesInvokeChannel(serviceName, methodName);
return channel === 'services.invoke'
? invokeWithNormalizedError<unknown>('services.invoke', serviceName, methodName, ...args)
: invokeWithNormalizedError<unknown>(channel, ...args);
});