diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c02e9a5b..4b564663 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, }, preload: { - plugins: [externalizeDepsPlugin()], + plugins: [externalizeDepsPlugin({ exclude: ["zod"] })], resolve: { alias: { "@shared": resolve("src/shared"), diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index fdd4bde6..e2ea3e85 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -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); }); diff --git a/src/main/ipc-recovery-handlers.ts b/src/main/ipc-recovery-handlers.ts index d5158f3d..0a6c9281 100644 --- a/src/main/ipc-recovery-handlers.ts +++ b/src/main/ipc-recovery-handlers.ts @@ -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, diff --git a/src/preload/index.ts b/src/preload/index.ts index a8fea974..8c1cf30a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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; } diff --git a/src/shared/contracts/preload-forwarders.ts b/src/shared/contracts/preload-forwarders.ts index 2112a21f..d25390be 100644 --- a/src/shared/contracts/preload-forwarders.ts +++ b/src/shared/contracts/preload-forwarders.ts @@ -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; +const managedPathSchema = z.string().min(1); + +export const PRELOAD_INPUT_SCHEMAS: Partial>> = { + 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, ): 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 { diff --git a/src/shared/contracts/schemas-company.ts b/src/shared/contracts/schemas-company.ts index eedf89db..7d3a15ee 100644 --- a/src/shared/contracts/schemas-company.ts +++ b/src/shared/contracts/schemas-company.ts @@ -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), diff --git a/src/shared/contracts/schemas-execution.ts b/src/shared/contracts/schemas-execution.ts index b6ade778..e148d2f1 100644 --- a/src/shared/contracts/schemas-execution.ts +++ b/src/shared/contracts/schemas-execution.ts @@ -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(), diff --git a/tests/unit/preload-bundling-contract.test.ts b/tests/unit/preload-bundling-contract.test.ts index 214ef016..6d7cd557 100644 --- a/tests/unit/preload-bundling-contract.test.ts +++ b/tests/unit/preload-bundling-contract.test.ts @@ -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["']\)/); }); diff --git a/tests/unit/preload-forwarders.test.ts b/tests/unit/preload-forwarders.test.ts index 8a21556a..45540e82 100644 --- a/tests/unit/preload-forwarders.test.ts +++ b/tests/unit/preload-forwarders.test.ts @@ -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(); @@ -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(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)]