Q120: every IPC call passes one gate - the app's own page, input checked against a typed contract

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 19:25:32 -04:00
1 parent 43591d4c99
commit 04c8cce0c4
10 files changed
+435 -103

No files matched your search

+7 -4
View File
@@ -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<CreateWindowOptions, "devServerUrl" | "rendererDist">): 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) {
@@ -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<C.CreateDocumentInput>,
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<C.UpdateDocumentInput>,
deleteDocument: z.strictObject({ orgId: id, documentId: id, deletedByEmployeeId: id }) satisfies z.ZodType<C.DeleteDocumentInput>,
listDocumentVersions: z.strictObject({ orgId: id, documentId: id }) satisfies z.ZodType<C.ListDocumentVersionsInput>,
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<C.CreateKnowledgeEntryInput>,
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<C.UpdateKnowledgeEntryInput>,
deleteKnowledgeEntry: z.strictObject({ orgId: id, entryId: id, deletedByEmployeeId: id }) satisfies z.ZodType<C.DeleteKnowledgeEntryInput>,
listKnowledgeRevisions: z.strictObject({ orgId: id, entryId: id }) satisfies z.ZodType<C.ListKnowledgeRevisionsInput>,
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<C.CreateAutomationRuleInput>,
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<C.UpdateAutomationRuleInput>,
deleteAutomationRule: z.strictObject({ orgId: id, ruleId: id }) satisfies z.ZodType<C.DeleteAutomationRuleInput>,
createMeeting: z.strictObject({
orgId: id, title: z.string(), meetingType: z.string(), scheduledAt: z.string().nullable(), durationMinutes: z.number(), agenda: z.string(),
}) satisfies z.ZodType<C.CreateMeetingInput>,
deleteMeeting: z.strictObject({ orgId: id, meetingId: id }) satisfies z.ZodType<C.DeleteMeetingInput>,
createSprint: z.strictObject({ orgId: id, name: z.string(), goal: z.string(), startDate: z.string(), endDate: z.string() }) satisfies z.ZodType<C.CreateSprintInput>,
deleteSprint: z.strictObject({ orgId: id, sprintId: id }) satisfies z.ZodType<C.DeleteSprintInput>,
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<C.CreateChatSpaceInput>,
sendMessage: z.strictObject({
orgId: id, spaceId: id, threadId: nullableId, senderType: sender, senderId: nullableId, bodyMd: z.string(), attachmentFileIds: z.array(id).optional(),
}) satisfies z.ZodType<C.SendMessageInput>,
editMessage: z.strictObject({ orgId: id, messageId: id, actorType: sender, actorId: nullableId, bodyMd: z.string() }) satisfies z.ZodType<C.EditMessageInput>,
deleteMessage: z.strictObject({ orgId: id, messageId: id, actorType: sender, actorId: nullableId }) satisfies z.ZodType<C.DeleteMessageInput>,
markChatSpaceRead: z.strictObject({ orgId: id, employeeId: id, spaceId: id }) satisfies z.ZodType<C.MarkChatSpaceReadInput>,
};
+24
View File
@@ -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<unknown> ? K : never }[keyof DesktopApi];
export type InputOf<K extends InvokedMethod> = Parameters<DesktopApi[K]> 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<InputOf<K>> } = {
loadSnapshot: noInput,
pickDirectory: noInput,
listConnectorCatalog: noInput,
windowMinimize: noInput,
windowMaximize: noInput,
windowClose: noInput,
...organizationContracts,
...collaborationContracts,
...platformContracts,
};
@@ -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<C.BootstrapOnboardingInput>,
setActiveOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType<C.SetActiveOrganizationInput>,
setUiPreferences: z.strictObject({ theme: z.enum(["system", "light", "dark"]).optional(), language: z.enum(["en", "zh"]).optional() }) satisfies z.ZodType<C.SetUiPreferencesInput>,
deleteOrganization: z.strictObject({ orgId: id }) satisfies z.ZodType<C.DeleteOrganizationInput>,
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<C.CreateOrganizationInput>,
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<C.CreateAgentInput>,
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<C.UpdateAgentInput>,
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<C.UpdateEmployeeProfileInput>,
setAutonomousPaused: z.strictObject({ paused: z.boolean() }) satisfies z.ZodType<C.SetAutonomousPausedInput>,
stopRun: z.strictObject({ runId: id }) satisfies z.ZodType<C.StopRunInput>,
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<C.CreateTaskInput>,
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<C.UpdateTaskInput>,
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
createProject: z.strictObject({
orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId,
}) satisfies z.ZodType<C.CreateProjectInput>,
createWorkspace: z.strictObject({ orgId: id, name: z.string(), localPath: z.string(), projectId: nullableId }) satisfies z.ZodType<C.CreateWorkspaceInput>,
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<C.CreateApprovalInput>,
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<C.DecideApprovalInput>,
syncEmployeeIdentity: z.strictObject({
orgId: id, employeeId: id, source: z.enum(IDENTITY_SOURCE), externalSubject: z.string(), email: z.string().nullable(),
}) satisfies z.ZodType<C.SyncEmployeeIdentityInput>,
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<C.GrantRoleInput>,
};
+85
View File
@@ -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<C.CreateProviderResponseInput>;
export const platformContracts = {
setIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES), value: z.string() }) satisfies z.ZodType<C.SetIntegrationKeyInput>,
clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType<C.ClearIntegrationKeyInput>,
saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType<C.SaveProviderCredentialInput>,
revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType<C.RevokeProviderCredentialInput>,
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<C.StartProviderOAuthInput>,
cancelProviderOAuth: z.strictObject({ providerKey: z.string() }) satisfies z.ZodType<C.CancelProviderOAuthInput>,
checkProviderHealth: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType<C.CheckProviderHealthInput>,
listProviderModels: z.strictObject({ providerKey: z.string(), secret: z.string().optional(), orgId: id.optional(), providerId: id.optional() }) satisfies z.ZodType<C.ListProviderModelsInput>,
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<C.UpdateProviderPolicyInput>,
setProviderPrices: z.strictObject({
orgId: id, providerId: id, inputDollarsPerMtok: z.number().nullable(), cachedInputDollarsPerMtok: z.number().nullable(), outputDollarsPerMtok: z.number().nullable(),
}) satisfies z.ZodType<C.SetProviderPricesInput>,
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<C.CancelProviderResponseInput>,
createSocialAccount: z.strictObject({
orgId: id, platform: z.enum(SOCIAL_PLATFORM), handle: z.string(), displayName: z.string(), createdByEmployeeId: id, authorizedEmployeeId: nullableId,
}) satisfies z.ZodType<C.CreateSocialAccountInput>,
launchSocialLogin: z.strictObject({ orgId: id, socialAccountId: id, employeeId: id }) satisfies z.ZodType<C.LaunchSocialLoginInput>,
createScheduledPost: z.strictObject({
orgId: id, socialAccountId: id, bodyMd: z.string(), scheduledFor: z.string(), createdByEmployeeId: id,
}) satisfies z.ZodType<C.CreateScheduledPostInput>,
setScheduledPostState: z.strictObject({
orgId: id, scheduledPostId: id, state: z.enum(["draft", "scheduled", "published", "cancelled"]), employeeId: id,
}) satisfies z.ZodType<C.SetScheduledPostStateInput>,
importFile: z.strictObject({ orgId: id }) satisfies z.ZodType<C.ImportFileInput>,
openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType<C.OpenFileInput>,
setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType<C.SetFilePinnedInput>,
createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType<C.CreateMigrationReportInput>,
createRollbackSnapshot: z.strictObject({ orgId: id, migrationReportId: id, createdByEmployeeId: id }) satisfies z.ZodType<C.CreateRollbackSnapshotInput>,
setRollbackSnapshotActive: z.strictObject({ orgId: id, snapshotId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType<C.SetRollbackSnapshotActiveInput>,
resolveMigrationReconciliation: z.strictObject({ orgId: id, reconciliationItemId: id, employeeId: id }) satisfies z.ZodType<C.ResolveMigrationReconciliationInput>,
setFeatureFlag: z.strictObject({ orgId: id, flagId: id, enabled: z.boolean() }) satisfies z.ZodType<C.SetFeatureFlagInput>,
runChaosDrill: z.strictObject({ orgId: id, drillId: id }) satisfies z.ZodType<C.RunChaosDrillInput>,
runSdlcReview: z.strictObject({ orgId: id, reviewAgentId: id, targetType: z.enum(SDLC_REVIEW_TARGET_TYPE), targetRef: z.string() }) satisfies z.ZodType<C.RunSdlcReviewInput>,
runEvalSuite: z.strictObject({ orgId: id, suiteId: id }) satisfies z.ZodType<C.RunEvalSuiteInput>,
setPrBotGate: z.strictObject({ orgId: id, gateId: id, enabled: z.boolean() }) satisfies z.ZodType<C.SetPrBotGateInput>,
createComplianceExport: z.strictObject({
orgId: id, subjectType: z.enum(["employee", "agent"]), subjectId: id, exportType: z.enum(COMPLIANCE_EXPORT_TYPE), requestedByEmployeeId: id,
}) satisfies z.ZodType<C.CreateComplianceExportInput>,
createAuditReplay: z.strictObject({ orgId: id, threadId: id, createdByEmployeeId: id }) satisfies z.ZodType<C.CreateAuditReplayInput>,
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<C.CreateLegalHoldInput>,
setLegalHoldState: z.strictObject({ orgId: id, legalHoldId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType<C.SetLegalHoldStateInput>,
createRetentionFreeze: z.strictObject({
orgId: id, scopeType: z.enum(["organization", "chat_space", "file_object"]), scopeId: id, reason: z.string(), createdByEmployeeId: id,
}) satisfies z.ZodType<C.CreateRetentionFreezeInput>,
setRetentionFreezeState: z.strictObject({ orgId: id, retentionFreezeId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType<C.SetRetentionFreezeStateInput>,
connectComposioToolkit: z.strictObject({ orgId: id, toolkitSlug: z.string(), label: z.string() }) satisfies z.ZodType<C.ConnectComposioToolkitInput>,
disconnectComposioToolkit: z.strictObject({ orgId: id, toolkitSlug: z.string(), label: z.string() }) satisfies z.ZodType<C.DisconnectComposioToolkitInput>,
refreshComposioAccounts: z.strictObject({ orgId: id }) satisfies z.ZodType<C.RefreshComposioAccountsInput>,
grantConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string(), accountLabel: z.string(), employeeId: id }) satisfies z.ZodType<C.GrantConnectorInput>,
revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType<C.RevokeConnectorInput>,
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<C.SearchInput>,
};
+35
View File
@@ -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<IpcMainInvokeEvent, "sender" | "senderFrame">, 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 <K extends InvokedMethod>(name: K, run: (input: InputOf<K>) => 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<InputOf<K>> = 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);
});
};
}
+100 -95
View File
@@ -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<string, unknown>) =>
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());
}
+14 -3
View File
@@ -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);
+2 -1
View File
@@ -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,
+43
View File
@@ -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<string, (event: unknown, raw: unknown) => 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" } });
});
});