1import { execFile as execFileCallback } from "node:child_process";2import { constants as fsConstants } from "node:fs";3import fs, { type FileHandle } from "node:fs/promises";4import { createRequire } from "node:module";5
import os from "node:os";
6import path from "node:path";
7import { promisify } from "node:util";
8
9import type * as Electron from "electron";
10
11import { DirectoryListingCoordinator } from "../../../../src/filesystem/directoryListingCoordinator";
12import type { WorkspaceFileChangeEvent as DirectoryWorkspaceFileChangeEvent } from "../../../../src/filesystem/workspaceFileEvents";
13import { MAX_ATTACHMENT_UPLOAD_BYTE_SIZE } from "../../../../src/shared/attachments";
14import type { WorkspaceFileChangeEvent as PreviewFileChangeEvent } from "../../../../src/shared/fileVersion";
15import { isPathInside, resolvePathInsideRootForBoundaryCheck } from "../../../../src/utils/paths";
16import {
17 type AuthorizeUploadSourceInput,
18 type CopyFileToWorkspaceUploadsInput,
19 type CopyFileToWorkspaceUploadsOutput,
20 type CopyPathInput,
21 type CreateDirectoryInput,
22 DESKTOP_EVENT_CHANNELS,
23 DESKTOP_IPC_CHANNELS,
24 type ExplorerEntry,
25 type ListDirectoryInput,
26 type OpenPathInput,
27 type PickCanvasSavePathInput,
28 type PickDirectoryInput,
29 type PreferredFileAppInput,
30 type PreviewOSFileInput,
31 type ReadFileForPreviewInput,
32 type ReadFileInput,
33 type RenamePathInput,
34 type RevealPathInput,
35 type SaveExportedFileInput,
36 type TrashPathInput,
37 type WatchWorkspaceDirectoryInput,
38 type WriteFileInput,
39} from "../../src/lib/desktopApi";
40import {
41 authorizeUploadSourceInputSchema,
42 copyFileToWorkspaceUploadsInputSchema,
43 copyPathInputSchema,
44 copyTextInputSchema,
45 createDirectoryInputSchema,
46 listDirectoryInputSchema,
47 openPathInputSchema,
48 pickCanvasSavePathInputSchema,
49 pickDirectoryInputSchema,
50 preferredFileAppInputSchema,
51 previewOSFileInputSchema,
52 readFileForPreviewInputSchema,
53 readFileInputSchema,
54 renamePathInputSchema,
55 revealPathInputSchema,
56 saveExportedFileInputSchema,
57 trashPathInputSchema,
58 watchWorkspaceDirectoryInputSchema,
59 writeFileInputSchema,
60} from "../../src/lib/desktopSchemas";
61import { resolveDesktopBuiltinSkillRootsForReveal } from "../services/desktopBuiltinPaths";
62import { isExplorerEntryHidden } from "../services/explorerVisibility";
63import {
64 DEFAULT_PREVIEW_MAX_BYTES,
65 readCappedFilePreview,
66 readFileChangeVersion,
67} from "../services/filePreviewRead";
68import {
69 resolveAllowedDirectoryPath,
70 resolveAllowedPath,
71 resolveAllowedRevealPath,
72 resolveAllowedSaveExportSourcePath,
73} from "../services/ipcSecurity";
74import { WorkspaceDirectoryWatcher } from "../services/workspaceDirectoryWatcher";
75import type { DesktopIpcModuleContext } from "./types";
76
77const execFile = promisify(execFileCallback);
78const require = createRequire(import.meta.url);
79const { app, BrowserWindow, clipboard, dialog, shell } = require("electron") as typeof Electron;
80
81export const MAX_READ_FILE_BYTES = 5 * 1024 * 1024;
82const DEFAULT_WORKSPACE_UPLOADS_DIR_NAME = "User Uploads";
83const MAX_AUTHORIZED_UPLOAD_SOURCES_PER_SENDER = 64;
84
85type UploadAuthorizationOwnerKey = string;
86type AuthorizedUploadSource = {
87 dev: number;
88 ino: number;
89 size: number;
90 mtimeMs: number;
91};
92type AuthorizedUploadSources = Map<
93 UploadAuthorizationOwnerKey,
94 Map<string, AuthorizedUploadSource>
95>;
96
97function uploadAuthorizationOwnerKey(
98 event: Electron.IpcMainInvokeEvent,
99): UploadAuthorizationOwnerKey {
100 const webContentsId = typeof event.sender?.id === "number" ? event.sender.id : "unknown";
101 const processId = typeof event.processId === "number" ? event.processId : "unknown";
102 const frameId = typeof event.frameId === "number" ? event.frameId : "unknown";
103 return `${webContentsId}:${processId}:${frameId}`;
104}
105
106function consumeAuthorizedUploadSource(
107 authorizedUploadSources: AuthorizedUploadSources,
108 ownerKey: UploadAuthorizationOwnerKey,
109 sourcePath: string,
110): AuthorizedUploadSource | null {
111 const ownerSources = authorizedUploadSources.get(ownerKey);
112 const source = ownerSources?.get(sourcePath);
113 if (!ownerSources || !source) {
114 return null;
115 }
116
117 ownerSources.delete(sourcePath);
118 if (ownerSources.size === 0) {
119 authorizedUploadSources.delete(ownerKey);
120 }
121
122 return source;
123}
124
125function getAuthorizedUploadSource(
126 authorizedUploadSources: AuthorizedUploadSources,
127 ownerKey: UploadAuthorizationOwnerKey,
128 sourcePath: string,
129): AuthorizedUploadSource | null {
130 return authorizedUploadSources.get(ownerKey)?.get(sourcePath) ?? null;
131}
132
133function uploadSourceIdentityFromStat(stat: {
134 dev: number;
135 ino: number;
136 size: number;
137 mtimeMs: number;
138}): AuthorizedUploadSource {
139 return {
140 dev: stat.dev,
141 ino: stat.ino,
142 size: stat.size,
143 mtimeMs: stat.mtimeMs,
144 };
145}
146
147function uploadSourceIdentityMatches(
148 expected: AuthorizedUploadSource,
149 actual: AuthorizedUploadSource,
150): boolean {
151 return (
152 expected.dev === actual.dev &&
153 expected.ino === actual.ino &&
154 expected.size === actual.size &&
155 expected.mtimeMs === actual.mtimeMs
156 );
157}
158
159async function readUploadSourceIdentity(sourcePath: string): Promise<AuthorizedUploadSource> {
160 const sourceStat = await fs.lstat(sourcePath);
161 if (sourceStat.isSymbolicLink()) {
162 throw new Error("Upload source path must not be a symbolic link.");
163 }
164 if (!sourceStat.isFile()) {
165 throw new Error("Upload source path must be a file.");
166 }
167 return uploadSourceIdentityFromStat(sourceStat);
168}
169
170function sendPreviewFileChanged(
171 event: Electron.IpcMainInvokeEvent,
172 change: PreviewFileChangeEvent,
173): void {
174 if (typeof event.sender?.send === "function") {
175 event.sender.send(DESKTOP_EVENT_CHANNELS.previewFileChanged, change);
176 }
177}
178
179function explorerEntriesEqual(left: ExplorerEntry, right: ExplorerEntry): boolean {
180 return (
181 left.name === right.name &&
182 left.path === right.path &&
183 left.isDirectory === right.isDirectory &&
184 left.isHidden === right.isHidden &&
185 left.sizeBytes === right.sizeBytes &&
186 left.modifiedAtMs === right.modifiedAtMs
187 );
188}
189
190async function readExplorerDirectory(
191 directoryPath: string,
192 includeHidden: boolean,
193): Promise<ExplorerEntry[]> {
194 const entries = await fs.readdir(directoryPath, { withFileTypes: true });
195 const results = await Promise.all(
196 entries.map(async (entry) => {
197 const isHidden = isExplorerEntryHidden(entry.name);
198 if (!includeHidden && isHidden) {
199 return null;
200 }
201
202 let sizeBytes: number | null = null;
203 let modifiedAtMs: number | null = null;
204 try {
205 const stat = await fs.stat(path.join(directoryPath, entry.name));
206 sizeBytes = stat.size;
207 modifiedAtMs = stat.mtimeMs;
208 } catch {
209 // Ignore stat errors for broken symlinks etc.
210 }
211
212 return {
213 name: entry.name,
214 path: path.join(directoryPath, entry.name),
215 isDirectory: entry.isDirectory(),
216 isHidden,
217 sizeBytes,
218 modifiedAtMs,
219 };
220 }),
221 );
222
223 return results
224 .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
225 .sort((left, right) => {
226 if (left.isDirectory !== right.isDirectory) {
227 return left.isDirectory ? -1 : 1;
228 }
229 return left.name.localeCompare(right.name);
230 });
231}
232
233export function registerFilesIpc(context: DesktopIpcModuleContext): () => void {
234 const { handleDesktopInvoke, parseWithSchema, workspaceRoots } = context;
235 const directoryListings = new DirectoryListingCoordinator<ExplorerEntry>({
236 cacheResults: false,
237 isEntryEqual: explorerEntriesEqual,
238 readDirectory: async (input) => await readExplorerDirectory(input.path, input.includeHidden),
239 });
240 const directoryWatcher = new WorkspaceDirectoryWatcher();
241 const destroyListenerBySenderId = new Set<number>();
242
243 const invalidateWorkspaceFileChange = (event: DirectoryWorkspaceFileChangeEvent): void => {
244 for (const directoryPath of event.affectedDirectoryPaths) {
245 directoryListings.invalidate({
246 workspaceId: event.workspaceId,
247 path: directoryPath,
248 });
249 }
250 for (const subtreePath of event.invalidatedSubtreePaths) {
251 directoryListings.invalidate({
252 workspaceId: event.workspaceId,
253 path: subtreePath,
254 recursive: true,
255 });
256 }
257 };
258
259 // Source paths a trusted-renderer file picker (webUtils.getPathForFile) has
260 // resolved from a real user-selected File. Authorizations are scoped to the
261 // sender frame and consumed on copy so another renderer cannot reuse them.
262 const authorizedUploadSources: AuthorizedUploadSources = new Map();
263 const rememberAuthorizedUploadSource = async (
264 ownerKey: UploadAuthorizationOwnerKey,
265 sourcePath: string,
266 ): Promise<void> => {
267 const resolved = path.resolve(sourcePath);
268 const sourceIdentity = await readUploadSourceIdentity(resolved);
269 let ownerSources = authorizedUploadSources.get(ownerKey);
270 if (!ownerSources) {
271 ownerSources = new Map();
272 authorizedUploadSources.set(ownerKey, ownerSources);
273 }
274
275 ownerSources.delete(resolved);
276 ownerSources.set(resolved, sourceIdentity);
277 while (ownerSources.size > MAX_AUTHORIZED_UPLOAD_SOURCES_PER_SENDER) {
278 const oldest = ownerSources.keys().next().value;
279 if (oldest === undefined) break;
280 ownerSources.delete(oldest);
281 }
282 };
283
284 handleDesktopInvoke(
285 DESKTOP_IPC_CHANNELS.listDirectory,
286 async (_event, args: ListDirectoryInput) => {
287 await workspaceRoots.ensureApprovedWorkspaceRoots();
288 const input = parseWithSchema(listDirectoryInputSchema, args, "listDirectory options");
289 // resolveAllowedDirectoryPath also allows the app-managed one-off chats
290 // root, so global chat sessions list correctly even with no project roots.
291 const safePath = resolveAllowedDirectoryPath(
292 workspaceRoots.getApprovedWorkspaceRoots(),
293 input.path,
294 );
295
296 return await directoryListings.read({
297 workspaceId: input.workspaceId,
298 path: safePath,
299 includeHidden: input.includeHidden ?? false,
300 });
301 },
302 );
303
304 handleDesktopInvoke(
305 DESKTOP_IPC_CHANNELS.watchWorkspaceDirectory,
306 async (event, args: WatchWorkspaceDirectoryInput) => {
307 await workspaceRoots.ensureApprovedWorkspaceRoots();
308 const input = parseWithSchema(
309 watchWorkspaceDirectoryInputSchema,
310 args,
311 "watchWorkspaceDirectory options",
312 );
313 const safeRootPath = resolveAllowedDirectoryPath(
314 workspaceRoots.getApprovedWorkspaceRoots(),
315 input.rootPath,
316 );
317 const subscriberId = String(event.sender.id);
318 if (!destroyListenerBySenderId.has(event.sender.id)) {
319 destroyListenerBySenderId.add(event.sender.id);
320 event.sender.once("destroyed", () => {
321 directoryWatcher.unwatchSubscriber(subscriberId);
322 destroyListenerBySenderId.delete(event.sender.id);
323 });
324 }
325 return directoryWatcher.watch(
326 {
327 workspaceId: input.workspaceId,
328 rootPath: safeRootPath,
329 },
330 subscriberId,
331 (change) => {
332 invalidateWorkspaceFileChange(change);
333 if (!event.sender.isDestroyed()) {
334 event.sender.send(DESKTOP_EVENT_CHANNELS.workspaceFileChanged, change);
335 }
336 },
337 );
338 },
339 );
340
341 handleDesktopInvoke(
342 DESKTOP_IPC_CHANNELS.unwatchWorkspaceDirectory,
343 async (event, args: WatchWorkspaceDirectoryInput) => {
344 await workspaceRoots.ensureApprovedWorkspaceRoots();
345 const input = parseWithSchema(
346 watchWorkspaceDirectoryInputSchema,
347 args,
348 "unwatchWorkspaceDirectory options",
349 );
350 const safeRootPath = resolveAllowedDirectoryPath(
351 workspaceRoots.getApprovedWorkspaceRoots(),
352 input.rootPath,
353 );
354 directoryWatcher.unwatch(
355 {
356 workspaceId: input.workspaceId,
357 rootPath: safeRootPath,
358 },
359 String(event.sender.id),
360 );
361 },
362 );
363
364 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.readFile, async (_event, args: ReadFileInput) => {
365 const input = parseWithSchema(readFileInputSchema, args, "readFile options");
366 await workspaceRoots.ensureApprovedWorkspaceRoots();
367 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
368 const stat = await fs.stat(safePath);
369 if (!stat.isFile()) {
370 throw new Error("Path is not a file");
371 }
372 if (stat.size > MAX_READ_FILE_BYTES) {
373 throw new Error(
374 `File is too large to read fully (${stat.size} bytes exceeds ${MAX_READ_FILE_BYTES} bytes).`,
375 );
376 }
377 return { content: await fs.readFile(safePath, "utf8") };
378 });
379
380 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.writeFile, async (event, args: WriteFileInput) => {
381 const input = parseWithSchema(writeFileInputSchema, args, "writeFile options");
382 await workspaceRoots.ensureApprovedWorkspaceRoots();
383 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
384 await fs.writeFile(safePath, input.content, "utf8");
385 directoryListings.invalidatePathAcrossWorkspaces(path.dirname(safePath));
386 sendPreviewFileChanged(event, {
387 kind: "changed",
388 path: safePath,
389 version: await readFileChangeVersion(safePath),
390 });
391 });
392
393 handleDesktopInvoke(
394 DESKTOP_IPC_CHANNELS.readFileForPreview,
395 async (_event, args: ReadFileForPreviewInput) => {
396 const input = parseWithSchema(
397 readFileForPreviewInputSchema,
398 args,
399 "readFileForPreview options",
400 );
401 await workspaceRoots.ensureApprovedWorkspaceRoots();
402 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
403 return await readCappedFilePreview(safePath, input.maxBytes ?? DEFAULT_PREVIEW_MAX_BYTES);
404 },
405 );
406
407 handleDesktopInvoke(
408 DESKTOP_IPC_CHANNELS.getPreferredFileApp,
409 async (_event, args: PreferredFileAppInput) => {
410 const input = parseWithSchema(
411 preferredFileAppInputSchema,
412 args,
413 "getPreferredFileApp options",
414 );
415 await workspaceRoots.ensureApprovedWorkspaceRoots();
416 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
417 return await resolvePreferredFileAppLabel(safePath);
418 },
419 );
420
421 handleDesktopInvoke(
422 DESKTOP_IPC_CHANNELS.previewOSFile,
423 async (event, args: PreviewOSFileInput) => {
424 const input = parseWithSchema(previewOSFileInputSchema, args, "previewOSFile options");
425 await workspaceRoots.ensureApprovedWorkspaceRoots();
426 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
427 const win = BrowserWindow.fromWebContents(event.sender);
428 if (win) {
429 win.previewFile(safePath);
430 }
431 },
432 );
433
434 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.openPath, async (_event, args: OpenPathInput) => {
435 const input = parseWithSchema(openPathInputSchema, args, "openPath options");
436 await workspaceRoots.ensureApprovedWorkspaceRoots();
437 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
438 const errString = await shell.openPath(safePath);
439 if (errString) {
440 throw new Error(errString);
441 }
442 });
443
444 handleDesktopInvoke(
445 DESKTOP_IPC_CHANNELS.saveExportedFile,
446 async (event, args: SaveExportedFileInput) => {
447 const input = parseWithSchema(saveExportedFileInputSchema, args, "saveExportedFile options");
448 await workspaceRoots.ensureApprovedWorkspaceRoots();
449 const safeSourcePath = resolveAllowedSaveExportSourcePath(
450 workspaceRoots.getApprovedWorkspaceRoots(),
451 input.sourcePath,
452 );
453 const downloadsPath = (() => {
454 try {
455 return app.getPath("downloads");
456 } catch {
457 return os.homedir();
458 }
459 })();
460 const defaultPath = path.join(downloadsPath || os.homedir(), input.defaultFileName);
461 const ownerWindow =
462 BrowserWindow.fromWebContents(event.sender) ??
463 BrowserWindow.getFocusedWindow() ??
464 undefined;
465 const result = ownerWindow
466 ? await dialog.showSaveDialog(ownerWindow, {
467 title: "Save research export",
468 defaultPath,
469 })
470 : await dialog.showSaveDialog({
471 title: "Save research export",
472 defaultPath,
473 });
474
475 if (result.canceled || !result.filePath) {
476 return null;
477 }
478
479 await fs.copyFile(safeSourcePath, result.filePath);
480 return result.filePath;
481 },
482 );
483
484 handleDesktopInvoke(
485 DESKTOP_IPC_CHANNELS.pickCanvasSavePath,
486 async (event, args: PickCanvasSavePathInput) => {
487 const input = parseWithSchema(
488 pickCanvasSavePathInputSchema,
489 args,
490 "pickCanvasSavePath options",
491 );
492 await workspaceRoots.ensureApprovedWorkspaceRoots();
493 const roots = workspaceRoots.getApprovedWorkspaceRoots();
494 const sourcePath = resolveAllowedPath(roots, input.sourcePath);
495 const extension = path.extname(sourcePath);
496 const baseName = path.basename(sourcePath, extension);
497 const defaultPath = path.join(path.dirname(sourcePath), `${baseName} copy${extension}`);
498 const ownerWindow =
499 BrowserWindow.fromWebContents(event.sender) ??
500 BrowserWindow.getFocusedWindow() ??
501 undefined;
502 const result = ownerWindow
503 ? await dialog.showSaveDialog(ownerWindow, {
504 title: "Save Canvas document as",
505 defaultPath,
506 })
507 : await dialog.showSaveDialog({
508 title: "Save Canvas document as",
509 defaultPath,
510 });
511 if (result.canceled || !result.filePath) {
512 return null;
513 }
514 return resolveAllowedPath(roots, result.filePath);
515 },
516 );
517
518 handleDesktopInvoke(
519 DESKTOP_IPC_CHANNELS.pickDirectory,
520 async (event, args: PickDirectoryInput) => {
521 const input = parseWithSchema(pickDirectoryInputSchema, args ?? {}, "pickDirectory options");
522 const ownerWindow =
523 BrowserWindow.fromWebContents(event.sender) ??
524 BrowserWindow.getFocusedWindow() ??
525 undefined;
526 const dialogOptions = {
527 title: input.title ?? "Select a directory",
528 properties: ["openDirectory"] as Array<"openDirectory">,
529 };
530 const result = ownerWindow
531 ? await dialog.showOpenDialog(ownerWindow, dialogOptions)
532 : await dialog.showOpenDialog(dialogOptions);
533 if (result.canceled) {
534 return null;
535 }
536 return result.filePaths[0] ?? null;
537 },
538 );
539
540 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.revealPath, async (_event, args: RevealPathInput) => {
541 const input = parseWithSchema(revealPathInputSchema, args, "revealPath options");
542 await workspaceRoots.ensureApprovedWorkspaceRoots();
543 const builtinSkillRoots = resolveDesktopBuiltinSkillRootsForReveal();
544 const safePath = resolveAllowedRevealPath(
545 workspaceRoots.getApprovedWorkspaceRoots(),
546 input.path,
547 builtinSkillRoots,
548 );
549 shell.showItemInFolder(safePath);
550 });
551
552 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.copyPath, async (_event, args: CopyPathInput) => {
553 const input = parseWithSchema(copyPathInputSchema, args, "copyPath options");
554 await workspaceRoots.ensureApprovedWorkspaceRoots();
555 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
556 clipboard.writeText(safePath);
557 });
558
559 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.copyText, async (_event, text: string) => {
560 const input = parseWithSchema(copyTextInputSchema, text, "copyText text");
561 clipboard.writeText(input);
562 });
563
564 handleDesktopInvoke(
565 DESKTOP_IPC_CHANNELS.authorizeUploadSource,
566 async (event, args: AuthorizeUploadSourceInput) => {
567 const input = parseWithSchema(
568 authorizeUploadSourceInputSchema,
569 args,
570 "authorizeUploadSource options",
571 );
572 await rememberAuthorizedUploadSource(uploadAuthorizationOwnerKey(event), input.sourcePath);
573 },
574 );
575
576 handleDesktopInvoke(
577 DESKTOP_IPC_CHANNELS.copyFileToWorkspaceUploads,
578 async (event, args: CopyFileToWorkspaceUploadsInput) => {
579 const input = parseWithSchema(
580 copyFileToWorkspaceUploadsInputSchema,
581 args,
582 "copyFileToWorkspaceUploads options",
583 );
584 await workspaceRoots.ensureApprovedWorkspaceRoots();
585 const copied = await copyFileToWorkspaceUploads(
586 workspaceRoots.getApprovedWorkspaceRoots(),
587 input,
588 authorizedUploadSources,
589 uploadAuthorizationOwnerKey(event),
590 );
591 directoryListings.invalidatePathAcrossWorkspaces(path.dirname(copied.path));
592 return copied;
593 },
594 );
595
596 handleDesktopInvoke(
597 DESKTOP_IPC_CHANNELS.createDirectory,
598 async (_event, args: CreateDirectoryInput) => {
599 const input = parseWithSchema(createDirectoryInputSchema, args, "createDirectory options");
600 await workspaceRoots.ensureApprovedWorkspaceRoots();
601 const roots = workspaceRoots.getApprovedWorkspaceRoots();
602 const safeParent = resolveAllowedDirectoryPath(roots, input.parentPath);
603 const targetPath = path.join(safeParent, input.name);
604 resolveAllowedPath(roots, targetPath);
605 await fs.mkdir(targetPath);
606 directoryListings.invalidatePathAcrossWorkspaces(safeParent);
607 },
608 );
609
610 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.renamePath, async (event, args: RenamePathInput) => {
611 const input = parseWithSchema(renamePathInputSchema, args, "renamePath options");
612 await workspaceRoots.ensureApprovedWorkspaceRoots();
613 const roots = workspaceRoots.getApprovedWorkspaceRoots();
614 const safePath = resolveAllowedPath(roots, input.path);
615 const targetPath = path.join(path.dirname(safePath), input.newName);
616 resolveAllowedPath(roots, targetPath);
617 await fs.rename(safePath, targetPath);
618 directoryListings.invalidatePathAcrossWorkspaces(path.dirname(safePath));
619 directoryListings.invalidatePathAcrossWorkspaces(safePath, true);
620 directoryListings.invalidatePathAcrossWorkspaces(targetPath, true);
621 sendPreviewFileChanged(event, {
622 kind: "deleted",
623 path: safePath,
624 version: null,
625 });
626 sendPreviewFileChanged(event, {
627 kind: "changed",
628 path: targetPath,
629 version: await readFileChangeVersion(targetPath),
630 });
631 });
632
633 handleDesktopInvoke(DESKTOP_IPC_CHANNELS.trashPath, async (event, args: TrashPathInput) => {
634 const input = parseWithSchema(trashPathInputSchema, args, "trashPath options");
635 await workspaceRoots.ensureApprovedWorkspaceRoots();
636 const safePath = resolveAllowedPath(workspaceRoots.getApprovedWorkspaceRoots(), input.path);
637 try {
638 await shell.trashItem(safePath);
639 directoryListings.invalidatePathAcrossWorkspaces(path.dirname(safePath));
640 directoryListings.invalidatePathAcrossWorkspaces(safePath, true);
641 } catch (trashError) {
642 const trashDetail = trashError instanceof Error ? trashError.message : String(trashError);
643 throw new Error(`Unable to move to Trash: ${trashDetail}`);
644 }
645 sendPreviewFileChanged(event, {
646 kind: "deleted",
647 path: safePath,
648 version: null,
649 });
650 });
651
652 return () => {
653 directoryWatcher.dispose();
654 directoryListings.clear();
655 };
656}
657
658async function copyFileToWorkspaceUploads(
659 workspaceRoots: string[],
660 input: CopyFileToWorkspaceUploadsInput,
661 authorizedUploadSources: AuthorizedUploadSources,
662 uploadAuthorizationOwnerKey: UploadAuthorizationOwnerKey,
663): Promise<CopyFileToWorkspaceUploadsOutput> {
664 const safeWorkspacePath = resolveAllowedDirectoryPath(workspaceRoots, input.workspacePath);
665 const sourcePath = path.resolve(input.sourcePath);
666 // Fail closed before touching the source: only paths the renderer picker
667 // authorized via getPathForFile may be copied. Otherwise an arbitrary path
668 // could be read into the workspace and exfiltrated as an attachment.
669 const authorizedSource = getAuthorizedUploadSource(
670 authorizedUploadSources,
671 uploadAuthorizationOwnerKey,
672 sourcePath,
673 );
674 if (!authorizedSource) {
675 throw new Error(
676 "Upload source path is not authorized. Select the file through the desktop file picker.",
677 );
678 }
679 if (authorizedSource.size > MAX_ATTACHMENT_UPLOAD_BYTE_SIZE) {
680 throw new Error("File too large to upload (max 100MB)");
681 }
682
683 const safeName = path.basename(input.filename);
684 if (!safeName || safeName === "." || safeName === "..") {
685 throw new Error("Invalid filename");
686 }
687
688 const requestedUploadsDir = input.uploadsDirectory
689 ? path.resolve(safeWorkspacePath, input.uploadsDirectory)
690 : path.resolve(safeWorkspacePath, DEFAULT_WORKSPACE_UPLOADS_DIR_NAME);
691 let resolvedUploadsDir: string;
692 try {
693 resolvedUploadsDir = await resolvePathInsideRootForBoundaryCheck(
694 safeWorkspacePath,
695 requestedUploadsDir,
696 );
697 } catch {
698 throw new Error("Uploads directory resolves outside the workspace.");
699 }
700
701 await fs.mkdir(resolvedUploadsDir, { recursive: true });
702 try {
703 resolvedUploadsDir = await resolvePathInsideRootForBoundaryCheck(
704 safeWorkspacePath,
705 resolvedUploadsDir,
706 );
707 } catch {
708 throw new Error("Uploads directory resolves outside the workspace.");
709 }
710
711 const ext = path.extname(safeName);
712 const base = safeName.slice(0, safeName.length - ext.length);
713 let targetPath = path.resolve(resolvedUploadsDir, safeName);
714 if (!isPathInside(resolvedUploadsDir, targetPath)) {
715 throw new Error("Invalid filename (path traversal)");
716 }
717
718 let counter = 1;
719 while (await fileExists(targetPath)) {
720 targetPath = path.resolve(resolvedUploadsDir, `${base}_${counter}${ext}`);
721 if (!isPathInside(resolvedUploadsDir, targetPath)) {
722 throw new Error("Invalid filename (path traversal)");
723 }
724 counter += 1;
725 }
726
727 const consumedSource = consumeAuthorizedUploadSource(
728 authorizedUploadSources,
729 uploadAuthorizationOwnerKey,
730 sourcePath,
731 );
732 if (!consumedSource) {
733 throw new Error(
734 "Upload source path is not authorized. Select the file through the desktop file picker.",
735 );
736 }
737 const sourceHandle = await openAuthorizedUploadSource(sourcePath, consumedSource);
738 try {
739 const sourceStat = await sourceHandle.stat();
740 if (sourceStat.size > MAX_ATTACHMENT_UPLOAD_BYTE_SIZE) {
741 throw new Error("File too large to upload (max 100MB)");
742 }
743
744 await fs.writeFile(targetPath, await sourceHandle.readFile(), { flag: "wx" });
745 return { filename: path.basename(targetPath), path: targetPath };
746 } finally {
747 await sourceHandle.close();
748 }
749}
750
751async function openAuthorizedUploadSource(
752 sourcePath: string,
753 authorizedSource: AuthorizedUploadSource,
754): Promise<FileHandle> {
755 const sourceStat = await fs.lstat(sourcePath).catch((error: unknown) => {
756 const detail = error instanceof Error ? error.message : String(error);
757 throw new Error(`Unable to read source file: ${detail}`);
758 });
759 if (sourceStat.isSymbolicLink()) {
760 throw new Error("Upload source path must not be a symbolic link.");
761 }
762 if (!sourceStat.isFile()) {
763 throw new Error("Source path is not a file");
764 }
765 const currentIdentity = uploadSourceIdentityFromStat(sourceStat);
766 if (!uploadSourceIdentityMatches(authorizedSource, currentIdentity)) {
767 throw new Error("Upload source file changed after authorization. Select the file again.");
768 }
769
770 const sourceHandle = await fs.open(
771 sourcePath,
772 fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
773 );
774 const openedStat = await sourceHandle.stat();
775 if (!openedStat.isFile()) {
776 await sourceHandle.close();
777 throw new Error("Source path is not a file");
778 }
779 if (!uploadSourceIdentityMatches(authorizedSource, uploadSourceIdentityFromStat(openedStat))) {
780 await sourceHandle.close();
781 throw new Error("Upload source file changed after authorization. Select the file again.");
782 }
783 return sourceHandle;
784}
785
786async function fileExists(filePath: string): Promise<boolean> {
787 try {
788 await fs.access(filePath);
789 return true;
790 } catch {
791 return false;
792 }
793}
794
795type AppCandidate = {
796 label: string;
797 bundleId: string;
798 knownPaths: string[];
799};
800
801async function resolvePreferredFileAppLabel(filePath: string): Promise<string | null> {
802 if (process.platform !== "darwin") return null;
803
804 const ext = path.extname(filePath).toLowerCase();
805 const candidates =
806 ext === ".docx" || ext === ".doc"
807 ? [
808 {
809 label: "Word",
810 bundleId: "com.microsoft.Word",
811 knownPaths: ["/Applications/Microsoft Word.app"],
812 },
813 {
814 label: "Pages",
815 bundleId: "com.apple.iWork.Pages",
816 knownPaths: ["/Applications/Pages.app"],
817 },
818 ]
819 : ext === ".xlsx" || ext === ".xls"
820 ? [
821 {
822 label: "Excel",
823 bundleId: "com.microsoft.Excel",
824 knownPaths: ["/Applications/Microsoft Excel.app"],
825 },
826 ]
827 : [];
828
829 for (const candidate of candidates) {
830 if (await appCandidateExists(candidate)) return candidate.label;
831 }
832
833 return null;
834}
835
836async function appCandidateExists(candidate: AppCandidate): Promise<boolean> {
837 for (const knownPath of candidate.knownPaths) {
838 try {
839 await fs.access(knownPath);
840 return true;
841 } catch {
842 // Keep checking.
843 }
844 }
845
846 try {
847 const { stdout } = await execFile("mdfind", [
848 `kMDItemCFBundleIdentifier == "${candidate.bundleId}"`,
849 ]);
850 return stdout.trim().length > 0;
851 } catch {
852 return false;
853 }
854}
855