diff --git a/src/main/app/create-window.ts b/src/main/app/create-window.ts index c383e648..b9b6cd3d 100644 --- a/src/main/app/create-window.ts +++ b/src/main/app/create-window.ts @@ -1,5 +1,6 @@ import { BrowserWindow } from "electron"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { attachMainWindowSecurity } from "./window-security"; export interface CreateWindowOptions { @@ -10,6 +11,11 @@ export interface CreateWindowOptions { language: string; } +/** The one page the main window may show and the IPC gate trusts: the dev server in development, the built index.html otherwise. */ +export function appPageUrl(options: Pick): string { + return options.devServerUrl ?? pathToFileURL(join(options.rendererDist, "index.html")).href; +} + export function createMainWindow(options: CreateWindowOptions): BrowserWindow { const win = new BrowserWindow({ width: 1280, @@ -33,10 +39,7 @@ export function createMainWindow(options: CreateWindowOptions): BrowserWindow { }, }); - attachMainWindowSecurity( - win, - options.devServerUrl ? new URL(options.devServerUrl).origin : null, - ); + attachMainWindowSecurity(win, appPageUrl(options)); win.once("ready-to-show", () => win.show()); if (options.devServerUrl) { diff --git a/src/main/app/ipc-contracts/collaboration.ts b/src/main/app/ipc-contracts/collaboration.ts new file mode 100644 index 00000000..a187869d --- /dev/null +++ b/src/main/app/ipc-contracts/collaboration.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import type * as C from "../../../shared/commands"; + +const id = z.string(); +const nullableId = z.string().nullable(); +const actor = z.enum(["employee", "agent"]); +const sender = z.enum(["employee", "agent", "board", "system"]); +const settings = z.record(z.string(), z.unknown()); + +export const collaborationContracts = { + createDocument: z.strictObject({ + orgId: id, title: z.string(), docType: z.string(), content: z.string(), authorAgentId: nullableId, reviewerAgentId: nullableId, + projectId: nullableId, goalId: nullableId, taskId: nullableId, + }) satisfies z.ZodType, + updateDocument: z.strictObject({ + orgId: id, documentId: id, title: z.string(), content: z.string(), status: z.string(), reason: z.string(), changedByType: actor, changedById: id, + }) satisfies z.ZodType, + deleteDocument: z.strictObject({ orgId: id, documentId: id, deletedByEmployeeId: id }) satisfies z.ZodType, + listDocumentVersions: z.strictObject({ orgId: id, documentId: id }) satisfies z.ZodType, + createKnowledgeEntry: z.strictObject({ + orgId: id, topic: z.string(), category: z.string(), content: z.string(), importance: z.string(), sourceType: z.string(), sourceId: nullableId, + scopeType: z.enum(["organization", "project", "goal", "task", "agent"]), scopeId: nullableId, validUntil: z.string().nullable(), supersedesId: nullableId, + }) satisfies z.ZodType, + updateKnowledgeEntry: z.strictObject({ + orgId: id, entryId: id, topic: z.string(), category: z.string(), content: z.string(), importance: z.string(), reason: z.string(), + changedByType: actor, changedById: id, + }) satisfies z.ZodType, + deleteKnowledgeEntry: z.strictObject({ orgId: id, entryId: id, deletedByEmployeeId: id }) satisfies z.ZodType, + listKnowledgeRevisions: z.strictObject({ orgId: id, entryId: id }) satisfies z.ZodType, + createAutomationRule: z.strictObject({ + orgId: id, name: z.string(), triggerEvent: z.string(), conditions: settings, actionType: z.string(), actionConfig: settings, + priority: z.number(), enabled: z.boolean(), + }) satisfies z.ZodType, + updateAutomationRule: z.strictObject({ + orgId: id, ruleId: id, name: z.string(), triggerEvent: z.string(), conditions: settings, actionType: z.string(), actionConfig: settings, + priority: z.number(), enabled: z.boolean(), + }) satisfies z.ZodType, + deleteAutomationRule: z.strictObject({ orgId: id, ruleId: id }) satisfies z.ZodType, + createMeeting: z.strictObject({ + orgId: id, title: z.string(), meetingType: z.string(), scheduledAt: z.string().nullable(), durationMinutes: z.number(), agenda: z.string(), + }) satisfies z.ZodType, + deleteMeeting: z.strictObject({ orgId: id, meetingId: id }) satisfies z.ZodType, + createSprint: z.strictObject({ orgId: id, name: z.string(), goal: z.string(), startDate: z.string(), endDate: z.string() }) satisfies z.ZodType, + deleteSprint: z.strictObject({ orgId: id, sprintId: id }) satisfies z.ZodType, + createChatSpace: z.strictObject({ + orgId: id, spaceType: z.enum(["company", "division", "department", "project", "pod", "ad_hoc", "direct"]), name: z.string(), topic: z.string(), + visibility: z.enum(["workspace", "restricted"]), retentionPolicy: z.enum(["standard_365d", "restricted_365d"]).optional(), + memberEmployeeIds: z.array(id).optional(), memberAgentIds: z.array(id).optional(), + }) satisfies z.ZodType, + sendMessage: z.strictObject({ + orgId: id, spaceId: id, threadId: nullableId, senderType: sender, senderId: nullableId, bodyMd: z.string(), attachmentFileIds: z.array(id).optional(), + }) satisfies z.ZodType, + editMessage: z.strictObject({ orgId: id, messageId: id, actorType: sender, actorId: nullableId, bodyMd: z.string() }) satisfies z.ZodType, + deleteMessage: z.strictObject({ orgId: id, messageId: id, actorType: sender, actorId: nullableId }) satisfies z.ZodType, + markChatSpaceRead: z.strictObject({ orgId: id, employeeId: id, spaceId: id }) satisfies z.ZodType, +}; diff --git a/src/main/app/ipc-contracts/index.ts b/src/main/app/ipc-contracts/index.ts new file mode 100644 index 00000000..d48feb3e --- /dev/null +++ b/src/main/app/ipc-contracts/index.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import type { DesktopApi } from "../../../shared/desktop-api"; +import { collaborationContracts } from "./collaboration"; +import { organizationContracts } from "./organization"; +import { platformContracts } from "./platform"; + +/** Every desktop API method the renderer invokes (not the event subscription or the constants). */ +export type InvokedMethod = { [K in keyof DesktopApi]: DesktopApi[K] extends (...args: never[]) => Promise ? K : never }[keyof DesktopApi]; +export type InputOf = Parameters extends [infer I] ? I : undefined; + +const noInput = z.undefined(); + +/** The runtime contract of each IPC call; a method without a schema, or a schema that disagrees with its type, fails the typecheck. */ +export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType> } = { + loadSnapshot: noInput, + pickDirectory: noInput, + listConnectorCatalog: noInput, + windowMinimize: noInput, + windowMaximize: noInput, + windowClose: noInput, + ...organizationContracts, + ...collaborationContracts, + ...platformContracts, +}; diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts new file mode 100644 index 00000000..d3e53f43 --- /dev/null +++ b/src/main/app/ipc-contracts/organization.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import type * as C from "../../../shared/commands"; +import { APPROVAL_STATE, RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../../shared/domain"; +import { IDENTITY_SOURCE, ROLE_KEY } from "../../../shared/identity-domain"; + +const id = z.string(); +const nullableId = z.string().nullable(); + +export const organizationContracts = { + bootstrapOnboarding: z.strictObject({ + companyName: z.string(), companyDescription: z.string(), employeeName: z.string(), workspacePath: z.string(), + executionMode: z.enum(["ai_ceo", "ai_cofounder"]), autoApproveHires: z.boolean(), reviewDeliverables: z.boolean(), + goalTitle: z.string(), goalDescription: z.string(), providerKey: z.string(), providerSecret: z.string(), providerModel: z.string(), + }) satisfies z.ZodType, + setActiveOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType, + setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType, + deleteOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType, + createOrganization: z.strictObject({ + organizationName: z.string(), description: z.string().optional(), mission: z.string(), goal: z.string(), employeeName: z.string(), + executionMode: z.enum(["ai_ceo", "ai_cofounder"]).optional(), autoApproveHires: z.boolean().optional(), reviewDeliverables: z.boolean().optional(), + }) satisfies z.ZodType, + createAgent: z.strictObject({ + orgId: id, name: z.string(), role: z.string(), department: z.string(), modelProviderId: nullableId, modelId: nullableId, + budgetCents: z.number(), managerAgentId: nullableId, title: z.string().optional(), roleLevel: z.string().optional(), + modelStrategy: z.string().optional(), workspaceId: nullableId.optional(), heartbeatEnabled: z.boolean().optional(), heartbeatIntervalSec: z.number().optional(), + }) satisfies z.ZodType, + updateAgent: z.strictObject({ + orgId: id, agentId: id, name: z.string().optional(), role: z.string(), title: z.string(), roleLevel: z.string(), department: z.string(), + budgetCents: z.number(), modelProviderId: nullableId, modelId: nullableId, + avatarEmoji: z.string().optional(), avatarColor: z.string().optional(), avatarImage: z.string().optional(), + }) satisfies z.ZodType, + updateEmployeeProfile: z.strictObject({ + orgId: id, employeeId: id, displayName: z.string(), avatarEmoji: z.string().optional(), avatarColor: z.string().optional(), avatarImage: z.string().optional(), + }) satisfies z.ZodType, + setAutonomousPaused: z.strictObject({ paused: z.boolean() }) satisfies z.ZodType, + stopRun: z.strictObject({ runId: id }) satisfies z.ZodType, + createTask: z.strictObject({ + orgId: id, title: z.string(), description: z.string(), status: z.enum(TASK_STATUS), assigneeAgentId: nullableId, risk: z.enum(RISK_LEVEL), + projectId: nullableId, goalId: nullableId, workspaceId: nullableId, priority: z.enum(TASK_PRIORITY), requiresFilesystem: z.boolean(), + writingMode: z.enum(WRITING_MODE).optional(), origin: z.enum(["chat_wake", "goal_review", "task_review"]).optional(), + }) satisfies z.ZodType, + updateTask: z.strictObject({ + orgId: id, taskId: id, title: z.string(), description: z.string(), assigneeAgentId: nullableId, risk: z.enum(RISK_LEVEL), + priority: z.enum(TASK_PRIORITY), parentTaskId: nullableId, + }) satisfies z.ZodType, + deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, + updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType, + runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, + createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType, + resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType, + createProject: z.strictObject({ + orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId, + }) satisfies z.ZodType, + createWorkspace: z.strictObject({ orgId: id, name: z.string(), localPath: z.string(), projectId: nullableId }) satisfies z.ZodType, + createApproval: z.strictObject({ + orgId: id, kind: z.string(), title: z.string(), details: z.string(), risk: z.enum(RISK_LEVEL), + requestedByType: z.enum(["employee", "agent", "system"]), requestedById: nullableId, + }) satisfies z.ZodType, + decideApproval: z.strictObject({ + orgId: id, approvalId: id, state: z.enum([APPROVAL_STATE.APPROVED, APPROVAL_STATE.DECLINED]), decidedByEmployeeId: id, notes: z.string().optional(), + }) satisfies z.ZodType, + syncEmployeeIdentity: z.strictObject({ + orgId: id, employeeId: id, source: z.enum(IDENTITY_SOURCE), externalSubject: z.string(), email: z.string().nullable(), + }) satisfies z.ZodType, + grantRole: z.strictObject({ + orgId: id, principalType: z.enum(["employee", "agent", "service_identity"]), principalId: id, roleKey: z.enum(ROLE_KEY), + scopeType: z.enum(["organization", "workspace", "project", "department"]), scopeId: id, grantedByEmployeeId: id, + }) satisfies z.ZodType, +}; diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts new file mode 100644 index 00000000..8278412a --- /dev/null +++ b/src/main/app/ipc-contracts/platform.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import type * as C from "../../../shared/commands"; +import { INTEGRATION_KEY_NAMES } from "../../../shared/commands"; +import { COMPLIANCE_EXPORT_TYPE } from "../../../shared/compliance-domain"; +import { SOCIAL_PLATFORM } from "../../../shared/domain"; +import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../../shared/provider-policy-domain"; +import { SDLC_REVIEW_TARGET_TYPE } from "../../../shared/sdlc-domain"; +import { SEARCH_ENTITY_TYPES } from "../../../shared/search-domain"; + +const id = z.string(); +const nullableId = z.string().nullable(); +const providerResponse = z.strictObject({ + orgId: id, providerId: id, workspaceId: nullableId.optional(), prompt: z.string(), instructions: z.string().nullable(), + executionMode: z.enum(PROVIDER_EXECUTION_MODE).optional(), +}) satisfies z.ZodType; + +export const platformContracts = { + setIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES), value: z.string() }) satisfies z.ZodType, + clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType, + saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType, + revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType, + startProviderOAuth: z.strictObject({ + providerKey: z.string(), enterpriseDomain: z.string().nullable().optional(), orgId: id.optional(), providerId: id.optional(), + employeeId: id.optional(), model: z.string().nullable().optional(), + }) satisfies z.ZodType, + cancelProviderOAuth: z.strictObject({ providerKey: z.string() }) satisfies z.ZodType, + checkProviderHealth: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType, + listProviderModels: z.strictObject({ providerKey: z.string(), secret: z.string().optional(), orgId: id.optional(), providerId: id.optional() }) satisfies z.ZodType, + checkProviderModel: z.strictObject({ orgId: id, providerId: id, model: z.string() }), + updateProviderPolicy: z.strictObject({ + orgId: id, providerId: id, dataPolicy: z.enum(PROVIDER_DATA_POLICY), backgroundEnabled: z.boolean(), monthlyRequestLimit: z.number(), + workspaceScope: z.enum(["organization", "workspace"]), contextWindowTokens: z.number(), + }) satisfies z.ZodType, + setProviderPrices: z.strictObject({ + orgId: id, providerId: id, inputDollarsPerMtok: z.number().nullable(), cachedInputDollarsPerMtok: z.number().nullable(), outputDollarsPerMtok: z.number().nullable(), + }) satisfies z.ZodType, + setGoalAuditor: z.strictObject({ orgId: id, providerId: id, modelId: z.string() }), + openExternalUrl: z.string(), + createProviderResponse: providerResponse, + streamProviderResponse: providerResponse, + cancelProviderResponse: z.strictObject({ orgId: id, requestId: id, providerResponseId: id.optional() }) satisfies z.ZodType, + createSocialAccount: z.strictObject({ + orgId: id, platform: z.enum(SOCIAL_PLATFORM), handle: z.string(), displayName: z.string(), createdByEmployeeId: id, authorizedEmployeeId: nullableId, + }) satisfies z.ZodType, + launchSocialLogin: z.strictObject({ orgId: id, socialAccountId: id, employeeId: id }) satisfies z.ZodType, + createScheduledPost: z.strictObject({ + orgId: id, socialAccountId: id, bodyMd: z.string(), scheduledFor: z.string(), createdByEmployeeId: id, + }) satisfies z.ZodType, + setScheduledPostState: z.strictObject({ + orgId: id, scheduledPostId: id, state: z.enum(["draft", "scheduled", "published", "cancelled"]), employeeId: id, + }) satisfies z.ZodType, + importFile: z.strictObject({ orgId: id }) satisfies z.ZodType, + openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType, + setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType, + createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType, + createRollbackSnapshot: z.strictObject({ orgId: id, migrationReportId: id, createdByEmployeeId: id }) satisfies z.ZodType, + setRollbackSnapshotActive: z.strictObject({ orgId: id, snapshotId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType, + resolveMigrationReconciliation: z.strictObject({ orgId: id, reconciliationItemId: id, employeeId: id }) satisfies z.ZodType, + setFeatureFlag: z.strictObject({ orgId: id, flagId: id, enabled: z.boolean() }) satisfies z.ZodType, + runChaosDrill: z.strictObject({ orgId: id, drillId: id }) satisfies z.ZodType, + runSdlcReview: z.strictObject({ orgId: id, reviewAgentId: id, targetType: z.enum(SDLC_REVIEW_TARGET_TYPE), targetRef: z.string() }) satisfies z.ZodType, + runEvalSuite: z.strictObject({ orgId: id, suiteId: id }) satisfies z.ZodType, + setPrBotGate: z.strictObject({ orgId: id, gateId: id, enabled: z.boolean() }) satisfies z.ZodType, + createComplianceExport: z.strictObject({ + orgId: id, subjectType: z.enum(["employee", "agent"]), subjectId: id, exportType: z.enum(COMPLIANCE_EXPORT_TYPE), requestedByEmployeeId: id, + }) satisfies z.ZodType, + createAuditReplay: z.strictObject({ orgId: id, threadId: id, createdByEmployeeId: id }) satisfies z.ZodType, + createLegalHold: z.strictObject({ + orgId: id, entityType: z.enum(["organization", "employee", "agent", "chat_thread", "file_object"]), entityId: id, reason: z.string(), createdByEmployeeId: id, + }) satisfies z.ZodType, + setLegalHoldState: z.strictObject({ orgId: id, legalHoldId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType, + createRetentionFreeze: z.strictObject({ + orgId: id, scopeType: z.enum(["organization", "chat_space", "file_object"]), scopeId: id, reason: z.string(), createdByEmployeeId: id, + }) satisfies z.ZodType, + setRetentionFreezeState: z.strictObject({ orgId: id, retentionFreezeId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType, + connectComposioToolkit: z.strictObject({ orgId: id, toolkitSlug: z.string(), label: z.string() }) satisfies z.ZodType, + disconnectComposioToolkit: z.strictObject({ orgId: id, toolkitSlug: z.string(), label: z.string() }) satisfies z.ZodType, + refreshComposioAccounts: z.strictObject({ orgId: id }) satisfies z.ZodType, + grantConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string(), accountLabel: z.string(), employeeId: id }) satisfies z.ZodType, + revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType, + approvalDrifts: z.strictObject({ orgId: id }), + search: z.strictObject({ + orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(), + }) satisfies z.ZodType, +}; diff --git a/src/main/app/ipc-gate.ts b/src/main/app/ipc-gate.ts new file mode 100644 index 00000000..25c5c8cc --- /dev/null +++ b/src/main/app/ipc-gate.ts @@ -0,0 +1,35 @@ +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron"; +import { z } from "zod"; +import { IPC_CHANNELS } from "../../shared/ipc"; +import { fail } from "../../shared/result"; +import { IPC_CONTRACTS, type InputOf, type InvokedMethod } from "./ipc-contracts"; +import { isAppPage } from "./window-security"; + +/** Why a call is refused, or null: only the app's own window, from its top frame, on the app's own page may call. */ +export function senderRefusal(event: Pick, mainWindow: BrowserWindow, appPage: string): string | null { + if (event.sender.id !== mainWindow.webContents.id) return "the call did not come from the app window"; + const frame = event.senderFrame; + if (!frame || frame.parent !== null) return "the call did not come from the app window's top frame"; + return isAppPage(frame.url, appPage) ? null : `the calling page ${frame.url} is not the app's own`; +} + +/** Registers an IPC call behind one gate: the caller is checked, then the input against the call's contract, before any service sees it. */ +export function createIpcGate(mainWindow: BrowserWindow, appPage: string) { + return (name: K, run: (input: InputOf) => unknown): void => { + ipcMain.handle(IPC_CHANNELS[name], (event, raw: unknown) => { + const refusal = senderRefusal(event, mainWindow, appPage); + if (refusal) { + console.warn(`[ipc] refused ${name}: ${refusal}`); + return fail("forbidden_sender", refusal); + } + const contract: z.ZodType> = IPC_CONTRACTS[name]; + const parsed = contract.safeParse(raw); + if (!parsed.success) { + const detail = z.prettifyError(parsed.error); + console.warn(`[ipc] invalid ${name} input: ${detail}`); + return fail("invalid_input", `${name}: ${detail}`); + } + return run(parsed.data); + }); + }; +} diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 24f308f2..1b782a22 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -1,4 +1,5 @@ -import { app, dialog, ipcMain, shell, type BrowserWindow } from "electron"; +import { createIpcGate } from "./ipc-gate"; +import { app, dialog, shell, type BrowserWindow } from "electron"; import type { DatabaseConnection } from "../database/connection"; import { IPC_CHANNELS } from "../../shared/ipc"; import { fail, ok, type DesktopResult } from "../../shared/result"; @@ -41,6 +42,8 @@ import { stopAllRuns, stopRun } from "../services/run-control"; export interface IpcDeps { connection: DatabaseConnection; mainWindow: BrowserWindow; + /** The app's own page (dev server URL or built index.html); calls from any other page are refused. */ + appPage: string; artifactRoot: string; /** Bundled built-in skills source (repo folder in dev, resources when packaged). */ builtinSkillsSource: string; @@ -49,6 +52,8 @@ export interface IpcDeps { } export function registerIpcHandlers(deps: IpcDeps): void { + // Every call passes one gate: the caller must be the app's own page, and the input must match its contract. + const handle = createIpcGate(deps.mainWindow, deps.appPage); const snapshot = new SnapshotService(deps.connection.db); const organization = new OrganizationService(deps.connection.db); const approvals = new ApprovalService(deps.connection.db); @@ -103,7 +108,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }; - ipcMain.handle(IPC_CHANNELS.loadSnapshot, () => { + handle("loadSnapshot", () => { try { return ok(snapshot.load()); } catch (error) { @@ -111,7 +116,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.bootstrapOnboarding, (_event, input) => { + handle("bootstrapOnboarding", (input) => { try { let summary: { orgId: string } | undefined; // Staging happens before the transaction (file mirroring), the import @@ -132,9 +137,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.createOrganization, (_event, input) => mutate(() => organization.createOrganization(input))); - ipcMain.handle(IPC_CHANNELS.setActiveOrganization, (_event, input) => mutate(() => organization.setActiveOrganization(input.orgId))); - ipcMain.handle(IPC_CHANNELS.setUiPreferences, (_event, input) => { + handle("createOrganization", (input) => mutate(() => organization.createOrganization(input))); + handle("setActiveOrganization", (input) => mutate(() => organization.setActiveOrganization(input.orgId))); + handle("setUiPreferences", (input) => { const before = snapshot.load().uiPreferences.language; const result = mutate(() => organization.updateUiPreferences(input)); if (result.ok && result.data.uiPreferences.language !== before) { @@ -147,25 +152,25 @@ export function registerIpcHandlers(deps: IpcDeps): void { } return result; }); - ipcMain.handle(IPC_CHANNELS.deleteOrganization, (_event, input) => mutate(() => organization.deleteOrganization(input.orgId))); - ipcMain.handle(IPC_CHANNELS.createAgent, (_event, input) => mutate(() => organization.createAgent(input))); - ipcMain.handle(IPC_CHANNELS.updateAgent, (_event, input) => mutate(() => organization.updateAgent(input))); - ipcMain.handle(IPC_CHANNELS.updateEmployeeProfile, (_event, input) => mutate(() => organization.updateEmployeeProfile(input))); - ipcMain.handle(IPC_CHANNELS.setAutonomousPaused, (_event, input) => mutate(() => { + handle("deleteOrganization", (input) => mutate(() => organization.deleteOrganization(input.orgId))); + handle("createAgent", (input) => mutate(() => organization.createAgent(input))); + handle("updateAgent", (input) => mutate(() => organization.updateAgent(input))); + handle("updateEmployeeProfile", (input) => mutate(() => organization.updateEmployeeProfile(input))); + handle("setAutonomousPaused", (input) => mutate(() => { organization.setAutonomousPaused(Boolean(input.paused)); // Pausing also stops the sessions already running, not just future scheduling. if (input.paused) stopAllRuns(); })); - ipcMain.handle(IPC_CHANNELS.stopRun, (_event, input) => mutate(() => { + handle("stopRun", (input) => mutate(() => { if (!stopRun(input.runId)) throw new Error("That run is not running anymore."); })); - ipcMain.handle(IPC_CHANNELS.setIntegrationKey, (_event, input) => mutate(() => integrationKeys.write(input.name, input.value))); - ipcMain.handle(IPC_CHANNELS.clearIntegrationKey, (_event, input) => mutate(() => integrationKeys.clear(input.name))); - ipcMain.handle(IPC_CHANNELS.createTask, (_event, input) => mutate(() => organization.createTask(input))); - ipcMain.handle(IPC_CHANNELS.updateTask, (_event, input) => mutate(() => organization.updateTask(input))); - ipcMain.handle(IPC_CHANNELS.deleteTask, (_event, input) => mutate(() => organization.deleteTask(input))); - ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); - ipcMain.handle(IPC_CHANNELS.runTask, async (_event, input) => { + handle("setIntegrationKey", (input) => mutate(() => integrationKeys.write(input.name, input.value))); + handle("clearIntegrationKey", (input) => mutate(() => integrationKeys.clear(input.name))); + handle("createTask", (input) => mutate(() => organization.createTask(input))); + handle("updateTask", (input) => mutate(() => organization.updateTask(input))); + handle("deleteTask", (input) => mutate(() => organization.deleteTask(input))); + handle("updateTaskStatus", (input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status))); + handle("runTask", async (input) => { try { await taskExecution.runTask(input); notifyDataChanged(); @@ -175,44 +180,44 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("task_run_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.createGoal, (_event, input) => mutate(() => organization.createGoal(input))); - ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => mutate(() => organization.createProject(input))); - ipcMain.handle(IPC_CHANNELS.createWorkspace, (_event, input) => mutate(() => organization.createWorkspace(input))); - ipcMain.handle(IPC_CHANNELS.createDocument, (_event, input) => mutate(() => documents.create(input))); - ipcMain.handle(IPC_CHANNELS.updateDocument, (_event, input) => mutate(() => documents.update(input))); - ipcMain.handle(IPC_CHANNELS.deleteDocument, (_event, input) => mutate(() => documents.delete(input))); - ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(input))); - ipcMain.handle(IPC_CHANNELS.deleteKnowledgeEntry, (_event, input) => mutate(() => knowledge.delete(input))); - ipcMain.handle(IPC_CHANNELS.resumeGoal, (_event, input) => mutate(() => resumeGoal(deps.connection.db, organization, input.orgId, input.goalId))); - ipcMain.handle(IPC_CHANNELS.updateKnowledgeEntry, (_event, input) => mutate(() => knowledge.update(input))); - ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () => + handle("createGoal", (input) => mutate(() => organization.createGoal(input))); + handle("createProject", (input) => mutate(() => organization.createProject(input))); + handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input))); + handle("createDocument", (input) => mutate(() => documents.create(input))); + handle("updateDocument", (input) => mutate(() => documents.update(input))); + handle("deleteDocument", (input) => mutate(() => documents.delete(input))); + handle("createKnowledgeEntry", (input) => mutate(() => knowledge.create(input))); + handle("deleteKnowledgeEntry", (input) => mutate(() => knowledge.delete(input))); + handle("resumeGoal", (input) => mutate(() => resumeGoal(deps.connection.db, organization, input.orgId, input.goalId))); + handle("updateKnowledgeEntry", (input) => mutate(() => knowledge.update(input))); + handle("listDocumentVersions", (input) => query("document_versions_failed", () => documents.versions(input.orgId, input.documentId).map((v) => ({ version: v.version, title: v.title, content: v.content, status: v.status, changedByType: v.changed_by_type, changedById: v.changed_by_id, reason: v.reason, createdAt: v.created_at, })))); - ipcMain.handle(IPC_CHANNELS.listKnowledgeRevisions, (_event, input) => query("knowledge_revisions_failed", () => + handle("listKnowledgeRevisions", (input) => query("knowledge_revisions_failed", () => knowledge.revisions(input.orgId, input.entryId).map((r) => ({ revision: r.revision, topic: r.topic, category: r.category, content: r.content, importance: r.importance, changedByType: r.changed_by_type, changedById: r.changed_by_id, reason: r.reason, createdAt: r.created_at, })))); - ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input))); - ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input))); - ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input))); - ipcMain.handle(IPC_CHANNELS.createMeeting, (_event, input) => mutate(() => meetingService.create(input))); - ipcMain.handle(IPC_CHANNELS.deleteMeeting, (_event, input) => mutate(() => meetingService.delete(input))); - ipcMain.handle(IPC_CHANNELS.createSprint, (_event, input) => mutate(() => sprintService.create(input))); - ipcMain.handle(IPC_CHANNELS.deleteSprint, (_event, input) => mutate(() => sprintService.delete(input))); - ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input))); - ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input))); - ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input))); - ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input))); - ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input))); - ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); - ipcMain.handle(IPC_CHANNELS.editMessage, (_event, input) => mutate(() => chat.editMessage(input))); - ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input))); - ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); - ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); - ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, async (_event, input) => { + handle("createAutomationRule", (input) => mutate(() => automation.create(input))); + handle("updateAutomationRule", (input) => mutate(() => automation.update(input))); + handle("deleteAutomationRule", (input) => mutate(() => automation.delete(input))); + handle("createMeeting", (input) => mutate(() => meetingService.create(input))); + handle("deleteMeeting", (input) => mutate(() => meetingService.delete(input))); + handle("createSprint", (input) => mutate(() => sprintService.create(input))); + handle("deleteSprint", (input) => mutate(() => sprintService.delete(input))); + handle("createApproval", (input) => mutate(() => approvals.create(input))); + handle("decideApproval", (input) => mutate(() => approvals.decide(input))); + handle("syncEmployeeIdentity", (input) => mutate(() => identityAccess.syncEmployeeIdentity(input))); + handle("grantRole", (input) => mutate(() => identityAccess.grantRole(input))); + handle("createChatSpace", (input) => mutate(() => chat.createSpace(input))); + handle("sendMessage", (input) => mutate(() => chat.sendMessage(input))); + handle("editMessage", (input) => mutate(() => chat.editMessage(input))); + handle("deleteMessage", (input) => mutate(() => chat.deleteMessage(input))); + handle("markChatSpaceRead", (input) => mutate(() => chat.markSpaceRead(input))); + handle("saveProviderCredential", (input) => mutate(() => providers.saveCredential(input))); + handle("revokeProviderCredential", async (input) => { try { await providers.revokeCredential(input); notifyDataChanged(); @@ -223,10 +228,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_revoke_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input))); - ipcMain.handle(IPC_CHANNELS.setProviderPrices, (_event, input) => mutate(() => providers.setPrices(input))); - ipcMain.handle(IPC_CHANNELS.setGoalAuditor, (_event, input) => mutate(() => setGoalAuditor(deps.connection.db, input))); - ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { + handle("updateProviderPolicy", (input) => mutate(() => providers.updatePolicy(input))); + handle("setProviderPrices", (input) => mutate(() => providers.setPrices(input))); + handle("setGoalAuditor", (input) => mutate(() => setGoalAuditor(deps.connection.db, input))); + handle("checkProviderHealth", async (input) => { try { await providers.checkHealth(input.orgId, input.providerId); notifyDataChanged(); @@ -235,21 +240,21 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_health_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.listProviderModels, async (_event, input) => { + handle("listProviderModels", async (input) => { try { return ok(await providers.listModels(input)); } catch (error) { return fail("provider_models_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.checkProviderModel, async (_event, input) => { + handle("checkProviderModel", async (input) => { try { return ok(await providers.checkModel(input)); } catch (error) { return fail("provider_model_check_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => { + handle("startProviderOAuth", async (input) => { const emit = (event: Record) => deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event }); try { @@ -283,11 +288,11 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_oauth_failed", message); } }); - ipcMain.handle(IPC_CHANNELS.cancelProviderOAuth, (_event, input) => { + handle("cancelProviderOAuth", (input) => { providerOAuth.cancel(input.providerKey); return ok(null); }); - ipcMain.handle(IPC_CHANNELS.openExternalUrl, async (_event, url: string) => { + handle("openExternalUrl", async (url) => { try { await shell.openExternal(normalizeExternalBrowserUrl(url)); return ok(null); @@ -295,7 +300,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("external_url_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => { + handle("createProviderResponse", async (input) => { try { await providers.createResponse(input); notifyDataChanged(); @@ -304,7 +309,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_response_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.streamProviderResponse, async (_event, input) => { + handle("streamProviderResponse", async (input) => { try { await providers.streamResponse(input, (streamEvent) => { deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-response-stream", event: streamEvent }); @@ -315,7 +320,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_stream_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.cancelProviderResponse, async (_event, input) => { + handle("cancelProviderResponse", async (input) => { try { await providers.cancelResponse(input); notifyDataChanged(); @@ -324,12 +329,12 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_cancel_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input))); - ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input))); - ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input))); - ipcMain.handle(IPC_CHANNELS.setFilePinned, (_event, input) => mutate(() => files.setPinned(input.orgId, input.fileId, input.pinned))); + handle("createSocialAccount", (input) => mutate(() => social.createAccount(input))); + handle("createScheduledPost", (input) => mutate(() => social.createScheduledPost(input))); + handle("setScheduledPostState", (input) => mutate(() => social.setScheduledPostState(input))); + handle("setFilePinned", (input) => mutate(() => files.setPinned(input.orgId, input.fileId, input.pinned))); - ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => { + handle("launchSocialLogin", (input) => { try { social.launchLoginWindow(input); return ok(null); @@ -338,7 +343,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.importFile, async (_event, input) => { + handle("importFile", async (input) => { try { const fileId = await files.importFile(input); if (fileId) notifyDataChanged(); @@ -348,7 +353,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.openFile, async (_event, input) => { + handle("openFile", async (input) => { try { await files.openFile(input); return ok(null); @@ -357,7 +362,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.pickDirectory, async () => { + handle("pickDirectory", async () => { try { const result = await dialog.showOpenDialog(deps.mainWindow, { properties: ["openDirectory", "createDirectory"] }); if (result.canceled || result.filePaths.length === 0) return ok(null); @@ -367,22 +372,22 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); - ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input))); - ipcMain.handle(IPC_CHANNELS.createRollbackSnapshot, (_event, input) => mutate(() => rollback.createSnapshot(input))); - ipcMain.handle(IPC_CHANNELS.setRollbackSnapshotActive, (_event, input) => mutate(() => rollback.setActive(input))); - ipcMain.handle(IPC_CHANNELS.resolveMigrationReconciliation, (_event, input) => mutate(() => rollback.resolveReconciliationItem(input))); - ipcMain.handle(IPC_CHANNELS.setFeatureFlag, (_event, input) => mutate(() => operations.setFeatureFlag(input))); - ipcMain.handle(IPC_CHANNELS.runChaosDrill, (_event, input) => mutate(() => operations.runChaosDrill(input))); - ipcMain.handle(IPC_CHANNELS.runSdlcReview, (_event, input) => mutate(() => sdlc.runReview(input))); - ipcMain.handle(IPC_CHANNELS.runEvalSuite, (_event, input) => mutate(() => sdlc.runEvalSuite(input))); - ipcMain.handle(IPC_CHANNELS.setPrBotGate, (_event, input) => mutate(() => sdlc.setPrBotGate(input))); - ipcMain.handle(IPC_CHANNELS.createComplianceExport, (_event, input) => mutate(() => compliance.createExport(input))); - ipcMain.handle(IPC_CHANNELS.createAuditReplay, (_event, input) => mutate(() => audit.createThreadReplay(input))); - ipcMain.handle(IPC_CHANNELS.createLegalHold, (_event, input) => mutate(() => compliance.createLegalHold(input))); - ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input))); - ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input))); - ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input))); - ipcMain.handle(IPC_CHANNELS.search, (_event, input) => { + handle("createMigrationReport", (input) => mutate(() => migrations.createReport(input))); + handle("createRollbackSnapshot", (input) => mutate(() => rollback.createSnapshot(input))); + handle("setRollbackSnapshotActive", (input) => mutate(() => rollback.setActive(input))); + handle("resolveMigrationReconciliation", (input) => mutate(() => rollback.resolveReconciliationItem(input))); + handle("setFeatureFlag", (input) => mutate(() => operations.setFeatureFlag(input))); + handle("runChaosDrill", (input) => mutate(() => operations.runChaosDrill(input))); + handle("runSdlcReview", (input) => mutate(() => sdlc.runReview(input))); + handle("runEvalSuite", (input) => mutate(() => sdlc.runEvalSuite(input))); + handle("setPrBotGate", (input) => mutate(() => sdlc.setPrBotGate(input))); + handle("createComplianceExport", (input) => mutate(() => compliance.createExport(input))); + handle("createAuditReplay", (input) => mutate(() => audit.createThreadReplay(input))); + handle("createLegalHold", (input) => mutate(() => compliance.createLegalHold(input))); + handle("setLegalHoldState", (input) => mutate(() => compliance.setLegalHoldState(input))); + handle("createRetentionFreeze", (input) => mutate(() => compliance.createRetentionFreeze(input))); + handle("setRetentionFreezeState", (input) => mutate(() => compliance.setRetentionFreezeState(input))); + handle("search", (input) => { try { return ok(search.search(input)); } catch (error) { @@ -392,14 +397,14 @@ export function registerIpcHandlers(deps: IpcDeps): void { const composioConnections = new ComposioConnectionService(deps.connection.db, integrationKeys); - ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => { + handle("listConnectorCatalog", async () => { try { return ok(await composioConnections.listCatalog()); } catch (error) { return fail("composio_catalog_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => { + handle("connectComposioToolkit", async (input) => { try { return ok(await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.label)); } catch (error) { @@ -409,7 +414,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { notifyDataChanged(); } }); - ipcMain.handle(IPC_CHANNELS.disconnectComposioToolkit, async (_event, input) => { + handle("disconnectComposioToolkit", async (input) => { try { await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug, input.label); return ok(snapshot.load()); @@ -419,16 +424,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { notifyDataChanged(); } }); - ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input))); - ipcMain.handle(IPC_CHANNELS.approvalDrifts, (_event, input) => { + handle("grantConnector", (input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input))); + handle("approvalDrifts", (input) => { try { return ok(approvalDrifts(deps.connection.db, input.orgId)); } catch (error) { return fail("approval_drift_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).revoke(input))); - ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => { + handle("revokeConnector", (input) => mutate(() => new ConnectorGrantsService(deps.connection.db).revoke(input))); + handle("refreshComposioAccounts", async (input) => { try { await composioConnections.refreshAccounts(input.orgId); return ok(snapshot.load()); @@ -438,7 +443,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { notifyDataChanged(); } }); - ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); - ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); - ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close()); + handle("windowMinimize", () => deps.mainWindow.minimize()); + handle("windowMaximize", () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); + handle("windowClose", () => deps.mainWindow.close()); } diff --git a/src/main/app/window-security.ts b/src/main/app/window-security.ts index 7911e18f..c308232f 100644 --- a/src/main/app/window-security.ts +++ b/src/main/app/window-security.ts @@ -1,10 +1,21 @@ import type { BrowserWindow } from "electron"; +import { fileURLToPath } from "node:url"; -export function attachMainWindowSecurity(win: BrowserWindow, devServerOrigin: string | null): void { +/** Whether a URL is the app's own page: the exact index.html it was built with, or any route of the dev server it is served by. */ +export function isAppPage(url: string, appPage: string): boolean { + if (!URL.canParse(url)) return false; + const target = new URL(url); + const page = new URL(appPage); + if (page.protocol !== "file:") return target.origin === page.origin; + if (target.protocol !== "file:") return false; + const path = (value: URL): string => (process.platform === "win32" ? fileURLToPath(value).toLowerCase() : fileURLToPath(value)); + return path(target) === path(page); +} + +export function attachMainWindowSecurity(win: BrowserWindow, appPage: string): void { win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); const blockNavigation = (event: Electron.Event, url: string): void => { - if (devServerOrigin && url.startsWith(devServerOrigin)) return; - if (url.startsWith("file://")) return; + if (isAppPage(url, appPage)) return; event.preventDefault(); }; win.webContents.on("will-navigate", blockNavigation); diff --git a/src/main/index.ts b/src/main/index.ts index 02398236..7dca83f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { DatabaseSync } from "node:sqlite"; import { DatabaseConnection } from "./database/connection"; -import { createMainWindow } from "./app/create-window"; +import { appPageUrl, createMainWindow } from "./app/create-window"; import { registerIpcHandlers } from "./app/ipc-handlers"; import { builtinSkillsSourceOverride, importStagedSkills, stageBuiltinSkillsLibrary } from "./services/builtin-skills"; import { ensureCeoDirectSpace } from "./services/ceo-direct-space"; @@ -44,6 +44,7 @@ if (!app.requestSingleInstanceLock()) { registerIpcHandlers({ connection, mainWindow: win, + appPage: appPageUrl({ devServerUrl: process.env.ELECTRON_RENDERER_URL, rendererDist: join(__dirname, "../renderer") }), artifactRoot: join(profileDir, "artifacts"), builtinSkillsSource, builtinSkillsLibrary, diff --git a/tests/unit/ipc-gate.test.ts b/tests/unit/ipc-gate.test.ts new file mode 100644 index 00000000..9ef29756 --- /dev/null +++ b/tests/unit/ipc-gate.test.ts @@ -0,0 +1,43 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, test, vi } from "vitest"; + +const registered = new Map unknown>(); +vi.mock("electron", () => ({ + ipcMain: { handle: (channel: string, listener: (event: unknown, raw: unknown) => unknown) => registered.set(channel, listener) }, +})); + +const { createIpcGate } = await import("../../src/main/app/ipc-gate"); +const { IPC_CHANNELS } = await import("../../src/shared/ipc"); + +const mainWindow = { webContents: { id: 1 } } as unknown as Electron.BrowserWindow; +const appPage = pathToFileURL(join(tmpdir(), "app", "index.html")).href; +const fromApp = { sender: { id: 1 }, senderFrame: { parent: null, url: `${appPage}#/work` } }; + +describe("the IPC gate", () => { + test("only the app's own page reaches a service, and only with input that matches the call's contract", () => { + const handle = createIpcGate(mainWindow, appPage); + const devHandle = createIpcGate(mainWindow, "http://localhost:5173/"); + const run = vi.fn((input: unknown) => ({ ok: true, data: input })); + handle("stopRun", run); + const call = (event: unknown, raw: unknown) => registered.get(IPC_CHANNELS.stopRun)!(event, raw); + + expect(call({ ...fromApp, sender: { id: 2 } }, { runId: "run_1" })).toMatchObject({ ok: false, error: { code: "forbidden_sender" } }); + expect(call({ ...fromApp, senderFrame: { parent: {}, url: "file:///C:/app/index.html" } }, { runId: "run_1" })).toMatchObject({ ok: false, error: { code: "forbidden_sender" } }); + expect(call({ ...fromApp, senderFrame: { parent: null, url: "https://evil.example/" } }, { runId: "run_1" })).toMatchObject({ ok: false, error: { code: "forbidden_sender" } }); + // Another local page is not the app's own, even though it is a file too. + const untrusted = pathToFileURL(join(tmpdir(), "Downloads", "untrusted.html")).href; + expect(call({ ...fromApp, senderFrame: { parent: null, url: untrusted } }, { runId: "run_1" })).toMatchObject({ ok: false, error: { code: "forbidden_sender" } }); + expect(call(fromApp, { runId: 7 })).toMatchObject({ ok: false, error: { code: "invalid_input" } }); + expect(call(fromApp, { runId: "run_1", extra: true })).toMatchObject({ ok: false, error: { code: "invalid_input" } }); + expect(run).not.toHaveBeenCalled(); + + expect(call(fromApp, { runId: "run_1" })).toEqual({ ok: true, data: { runId: "run_1" } }); + expect(run).toHaveBeenCalledTimes(1); + // In development the dev server's origin is the app's page. + devHandle("stopRun", run); + expect(call({ ...fromApp, senderFrame: { parent: null, url: "http://localhost:5173/#/work" } }, { runId: "run_2" })).toEqual({ ok: true, data: { runId: "run_2" } }); + expect(call({ ...fromApp, senderFrame: { parent: null, url: appPage } }, { runId: "run_3" })).toMatchObject({ ok: false, error: { code: "forbidden_sender" } }); + }); +});