refactor(ipc): validate write payloads in preload

This commit is contained in:
ZacharyZhang-NY committed 2026-04-24 01:31:29 -04:00
1 parent 3609456ca5
commit 227fd7faf2
9 files changed
+302 -35

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
},
},
preload: {
plugins: [externalizeDepsPlugin()],
plugins: [externalizeDepsPlugin({ exclude: ["zod"] })],
resolve: {
alias: {
"@shared": resolve("src/shared"),
+7 -10
View File
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import {
IPC_CHANNELS,
companyIdSchema,
companyInputSchema,
connectorDisabledModelsSchema,
connectorInputSchema,
@@ -73,13 +74,11 @@ export function registerCoreAdminHandlers({
});
registerHandle(IPC_CHANNELS.setCurrentCompany, async (_event, companyId) => {
if (typeof companyId !== "string" || !companyId) {
return fail("INVALID_COMPANY_ID", "A valid company id is required.");
}
if (!db.core.settings.hasCompany(companyId)) {
const parsed = companyIdSchema.parse(companyId);
if (!db.core.settings.hasCompany(parsed)) {
return fail("COMPANY_NOT_FOUND", "The selected company does not exist.");
}
db.core.settings.setCurrentCompany(companyId);
db.core.settings.setCurrentCompany(parsed);
publishDomainChanged();
return ok(true);
});
@@ -153,13 +152,11 @@ export function registerCoreAdminHandlers({
});
registerHandle(IPC_CHANNELS.deleteCompany, async (_event, companyId) => {
if (typeof companyId !== "string" || companyId.length === 0) {
return fail("INVALID_INPUT", "Company ID is required.");
}
if (!db.core.settings.hasCompany(companyId)) {
const parsed = companyIdSchema.parse(companyId);
if (!db.core.settings.hasCompany(parsed)) {
return fail("COMPANY_NOT_FOUND", "Company not found.");
}
db.core.company.deleteCompany(companyId);
db.core.company.deleteCompany(parsed);
publishDomainChanged();
return ok(true);
});
+1 -13
View File
@@ -1,18 +1,6 @@
import { z } from "zod";
import { IPC_CHANNELS } from "@shared/contracts";
import { IPC_CHANNELS, recoveryRetrySchema, recoveryTaskSchema } from "@shared/contracts";
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
const recoveryRetrySchema = z.object({
taskId: z.string(),
agentId: z.string(),
companyId: z.string(),
});
const recoveryTaskSchema = z.object({
taskId: z.string(),
companyId: z.string(),
});
export function registerRecoveryHandlers({
registerHandle,
db,
+11 -7
View File
@@ -3,19 +3,23 @@ import { IPC_CHANNELS } from "@shared/contracts/channels";
import type { DesktopApi } from "@shared/contracts/desktop-api";
import {
PRELOAD_FORWARDERS,
PRELOAD_INPUT_SCHEMAS,
mapPreloadInvokeArgs,
type PreloadForwardedApi,
} from "@shared/contracts/preload-forwarders";
import type { DesktopEvent } from "@shared/types";
function createForwardedApi(): PreloadForwardedApi {
const forwardedEntries = PRELOAD_FORWARDERS.map(({ api, channel, payload }) => [
api,
(...args: unknown[]) => ipcRenderer.invoke(
IPC_CHANNELS[channel],
...mapPreloadInvokeArgs(payload, args),
),
]);
const forwardedEntries = PRELOAD_FORWARDERS.map(({ api, channel, payload }) => {
const schema = PRELOAD_INPUT_SCHEMAS[api];
return [
api,
(...args: unknown[]) => ipcRenderer.invoke(
IPC_CHANNELS[channel],
...mapPreloadInvokeArgs(payload, args, schema),
),
];
});
return Object.fromEntries(forwardedEntries) as PreloadForwardedApi;
}
+152 -2
View File
@@ -1,5 +1,80 @@
import { z } from "zod";
import { IPC_CHANNELS } from "./channels";
import type { DesktopApi } from "./desktop-api";
import {
agentInputSchema,
heartbeatSettingsSchema,
hireRequestSchema,
triggerHeartbeatSchema,
} from "./schemas-agents";
import {
approvalDecisionSchema,
approvalInputSchema,
} from "./schemas-approvals";
import {
automationRuleInputSchema,
toggleAutomationRuleSchema,
} from "./schemas-automation";
import {
deleteEntitySchema,
entityGetterSchema,
} from "./schemas-badges";
import {
budgetPolicyInputSchema,
deleteBudgetPolicySchema,
resolveBudgetIncidentSchema,
} from "./schemas-budget";
import {
bootstrapOnboardingSchema,
companyIdSchema,
companyInputSchema,
connectorDisabledModelsSchema,
connectorInputSchema,
socialAccountInputSchema,
settingsSchema,
workspaceInputSchema,
} from "./schemas-company";
import {
removeCredentialInputSchema,
saveCredentialInputSchema,
testCredentialInputSchema,
} from "./schemas-credentials";
import {
assignSkillToAgentSchema,
cancelBrowserActionSchema,
cancelRunSchema,
checkSkillUpdateSchema,
deleteSkillSchema,
importSkillSchema,
openTerminalCommandSchema,
recoveryRetrySchema,
recoveryTaskSchema,
removeSkillFromAgentSchema,
runWorkflowSchema,
startRunSchema,
triggerBrowserLoginSchema,
workflowInputSchema,
} from "./schemas-execution";
import {
agentMessageInputSchema,
commentInputSchema,
documentInputSchema,
knowledgeEntryInputSchema,
markMessageReadSchema,
meetingInputSchema,
} from "./schemas-messages";
import {
oauthCancelInputSchema,
oauthManualCodeResponseSchema,
oauthPromptResponseSchema,
oauthStartInputSchema,
} from "./schemas-oauth";
import {
goalInputSchema,
projectInputSchema,
sprintInputSchema,
taskInputSchema,
} from "./schemas-tasks";
type DesktopFunctionKey = {
[Key in keyof DesktopApi]: DesktopApi[Key] extends (...args: infer _Args) => unknown ? Key : never;
@@ -138,9 +213,83 @@ export const PRELOAD_FORWARDERS = [
export type PreloadForwardedMethod = (typeof PRELOAD_FORWARDERS)[number]["api"];
export type PreloadForwardedApi = Pick<DesktopApi, PreloadForwardedMethod>;
const managedPathSchema = z.string().min(1);
export const PRELOAD_INPUT_SCHEMAS: Partial<Record<PreloadForwardedMethod, z.ZodType<unknown>>> = {
openPath: managedPathSchema,
openWorkProduct: entityGetterSchema,
saveCompany: companyInputSchema,
setCurrentCompany: companyIdSchema,
saveConnector: connectorInputSchema,
testConnector: connectorInputSchema.shape.id,
saveWorkspace: workspaceInputSchema,
saveAgent: agentInputSchema,
requestHire: hireRequestSchema,
saveGoal: goalInputSchema,
saveProject: projectInputSchema,
saveTask: taskInputSchema,
requestApproval: approvalInputSchema,
decideApproval: approvalDecisionSchema,
startTaskRun: startRunSchema,
cancelRun: cancelRunSchema,
updateSettings: settingsSchema,
deleteCompany: companyIdSchema,
deleteAgent: deleteEntitySchema,
deleteTask: deleteEntitySchema,
deleteGoal: deleteEntitySchema,
deleteProject: deleteEntitySchema,
deleteWorkspace: deleteEntitySchema,
deleteApproval: deleteEntitySchema,
deleteSecret: deleteEntitySchema,
setConnectorDisabledModels: connectorDisabledModelsSchema,
addComment: commentInputSchema,
setHeartbeat: heartbeatSettingsSchema,
triggerHeartbeat: triggerHeartbeatSchema,
bootstrapOnboarding: bootstrapOnboardingSchema,
openTerminalCommand: openTerminalCommandSchema,
saveSocialAccount: socialAccountInputSchema,
deleteSocialAccount: deleteEntitySchema,
cancelBrowserAction: cancelBrowserActionSchema,
triggerBrowserLogin: triggerBrowserLoginSchema,
saveMeeting: meetingInputSchema,
deleteMeeting: deleteEntitySchema,
saveDocument: documentInputSchema,
deleteDocument: deleteEntitySchema,
saveKnowledgeEntry: knowledgeEntryInputSchema,
deleteKnowledgeEntry: deleteEntitySchema,
saveSprint: sprintInputSchema,
deleteSprint: deleteEntitySchema,
saveAutomationRule: automationRuleInputSchema,
deleteAutomationRule: deleteEntitySchema,
toggleAutomationRule: toggleAutomationRuleSchema,
sendAgentMessage: agentMessageInputSchema,
markMessageRead: markMessageReadSchema,
saveWorkflow: workflowInputSchema,
deleteWorkflow: deleteEntitySchema,
runWorkflow: runWorkflowSchema,
importSkill: importSkillSchema,
deleteSkill: deleteSkillSchema,
assignSkillToAgent: assignSkillToAgentSchema,
removeSkillFromAgent: removeSkillFromAgentSchema,
checkSkillUpdate: checkSkillUpdateSchema,
saveBudgetPolicy: budgetPolicyInputSchema,
deleteBudgetPolicy: deleteBudgetPolicySchema,
resolveBudgetIncident: resolveBudgetIncidentSchema,
recoveryRetry: recoveryRetrySchema,
recoveryFail: recoveryTaskSchema,
saveCredential: saveCredentialInputSchema,
removeCredential: removeCredentialInputSchema,
testCredential: testCredentialInputSchema,
startOAuth: oauthStartInputSchema,
cancelOAuth: oauthCancelInputSchema,
submitOAuthPrompt: oauthPromptResponseSchema,
submitOAuthManualCode: oauthManualCodeResponseSchema,
};
export function mapPreloadInvokeArgs(
payload: PreloadPayloadSpec,
args: readonly unknown[],
schema?: z.ZodType<unknown>,
): readonly unknown[] {
if (payload.kind === "none") {
assertArgCount(payload.kind, args.length, 0);
@@ -149,11 +298,12 @@ export function mapPreloadInvokeArgs(
if (payload.kind === "single") {
assertArgCount(payload.kind, args.length, 1);
return [args[0]];
return [schema ? schema.parse(args[0]) : args[0]];
}
assertArgCount(payload.kind, args.length, payload.keys.length);
return [Object.fromEntries(payload.keys.map((key, index) => [key, args[index]]))];
const objectPayload = Object.fromEntries(payload.keys.map((key, index) => [key, args[index]]));
return [schema ? schema.parse(objectPayload) : objectPayload];
}
function assertArgCount(kind: PreloadPayloadSpec["kind"], actual: number, expected: number): void {
+2
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import { optionalId } from "./helpers";
export const companyIdSchema = z.string().uuid();
export const companyInputSchema = z.object({
id: optionalId,
name: z.string().min(2).max(80),
+11
View File
@@ -11,6 +11,17 @@ export const cancelRunSchema = z.object({
runId: z.string().uuid(),
});
export const recoveryRetrySchema = z.object({
taskId: z.string().uuid(),
agentId: z.string().uuid(),
companyId: z.string().uuid(),
});
export const recoveryTaskSchema = z.object({
taskId: z.string().uuid(),
companyId: z.string().uuid(),
});
export const runLogSchema = z.object({
companyId: z.string().uuid(),
runId: z.string().uuid(),
+3 -2
View File
@@ -3,11 +3,12 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("preload bundling contract", () => {
it("keeps Zod validation on the main-process IPC boundary", () => {
it("delegates preload IPC validation through the shared forwarder registry", () => {
const configSource = readFileSync(join(process.cwd(), "electron.vite.config.ts"), "utf8");
const preloadSource = readFileSync(join(process.cwd(), "src/preload/index.ts"), "utf8");
expect(configSource).not.toContain('exclude: ["zod"]');
expect(configSource).toContain('exclude: ["zod"]');
expect(preloadSource).toContain("PRELOAD_INPUT_SCHEMAS");
expect(preloadSource).not.toContain('from "@shared/contracts"');
expect(preloadSource).not.toMatch(/from ["']zod["']|require\(["']zod["']\)/);
});
+114
View File
@@ -4,10 +4,82 @@ import { describe, expect, it } from "vitest";
import {
IPC_CHANNELS,
PRELOAD_FORWARDERS,
PRELOAD_INPUT_SCHEMAS,
mapPreloadInvokeArgs,
type PreloadPayloadSpec,
} from "../../src/shared/contracts";
const validatedInputMethods = [
"addComment",
"assignSkillToAgent",
"bootstrapOnboarding",
"cancelBrowserAction",
"cancelOAuth",
"cancelRun",
"checkSkillUpdate",
"decideApproval",
"deleteAgent",
"deleteApproval",
"deleteAutomationRule",
"deleteBudgetPolicy",
"deleteCompany",
"deleteDocument",
"deleteGoal",
"deleteKnowledgeEntry",
"deleteMeeting",
"deleteProject",
"deleteSecret",
"deleteSkill",
"deleteSocialAccount",
"deleteSprint",
"deleteTask",
"deleteWorkflow",
"deleteWorkspace",
"importSkill",
"markMessageRead",
"openPath",
"openTerminalCommand",
"openWorkProduct",
"recoveryFail",
"recoveryRetry",
"removeCredential",
"removeSkillFromAgent",
"requestApproval",
"requestHire",
"resolveBudgetIncident",
"runWorkflow",
"saveAgent",
"saveAutomationRule",
"saveBudgetPolicy",
"saveCompany",
"saveConnector",
"saveCredential",
"saveDocument",
"saveGoal",
"saveKnowledgeEntry",
"saveMeeting",
"saveProject",
"saveSocialAccount",
"saveSprint",
"saveTask",
"saveWorkflow",
"saveWorkspace",
"sendAgentMessage",
"setConnectorDisabledModels",
"setCurrentCompany",
"setHeartbeat",
"startOAuth",
"startTaskRun",
"submitOAuthManualCode",
"submitOAuthPrompt",
"testConnector",
"testCredential",
"toggleAutomationRule",
"triggerBrowserLogin",
"triggerHeartbeat",
"updateSettings",
] as const;
describe("preload forwarders", () => {
it("keeps API methods and IPC channels unique", () => {
const apiNames = new Set<string>();
@@ -47,6 +119,48 @@ describe("preload forwarders", () => {
);
});
it("validates write and action payloads before invoking main", () => {
const startRun = PRELOAD_FORWARDERS.find((forwarder) => forwarder.api === "startTaskRun");
const importSkill = PRELOAD_FORWARDERS.find((forwarder) => forwarder.api === "importSkill");
expect(startRun).toBeDefined();
expect(importSkill).toBeDefined();
expect(() =>
mapPreloadInvokeArgs(startRun!.payload, [{ companyId: "bad", taskId: "bad" }], PRELOAD_INPUT_SCHEMAS.startTaskRun)
).toThrow();
expect(() =>
mapPreloadInvokeArgs(importSkill!.payload, ["company-1", ""], PRELOAD_INPUT_SCHEMAS.importSkill)
).toThrow();
});
it("keeps every preload input schema attached to a registered forwarder", () => {
const apiNames = new Set<string>(PRELOAD_FORWARDERS.map((forwarder) => forwarder.api));
for (const method of Object.keys(PRELOAD_INPUT_SCHEMAS)) {
expect(apiNames.has(method)).toBe(true);
}
});
it("keeps write and action preload inputs behind schemas", () => {
expect(Object.keys(PRELOAD_INPUT_SCHEMAS).sort()).toEqual([...validatedInputMethods].sort());
});
it("samples read-path payload mapping without runtime schemas", () => {
const readPathSamples = [
{ api: "getRunLog", args: [{ companyId: "bad", runId: "bad" }] },
{ api: "listSkills", args: ["bad-company"] },
{ api: "getBudgetStatus", args: ["bad-company"] },
] as const;
for (const sample of readPathSamples) {
const forwarder = PRELOAD_FORWARDERS.find((candidate) => candidate.api === sample.api);
expect(forwarder).toBeDefined();
expect(PRELOAD_INPUT_SCHEMAS[sample.api]).toBeUndefined();
expect(mapPreloadInvokeArgs(forwarder!.payload, sample.args)).toHaveLength(1);
}
});
it("keeps preload invoking through the shared registry", () => {
const preloadSource = readFileSync(join(process.cwd(), "src/preload/index.ts"), "utf8");
const staticChannelAccesses = [...preloadSource.matchAll(/IPC_CHANNELS\.([A-Za-z0-9_]+)/g)]