refactor(ipc): validate write payloads in preload
This commit is contained in:
1 parent
3609456ca5
commit
227fd7faf2
9 files changed
+302
-35
No files matched your search
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [externalizeDepsPlugin({ exclude: ["zod"] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": resolve("src/shared"),
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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,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["']\)/);
|
||||
});
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user