diff --git a/packages/desktop/src/preload-sandbox.test.ts b/packages/desktop/src/preload-sandbox.test.ts new file mode 100644 index 000000000..4349ce32a --- /dev/null +++ b/packages/desktop/src/preload-sandbox.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js"; + +// The preload runs inside Electron's sandbox and is tsc-compiled (not bundled), so at +// runtime it may only load Electron's sandbox allowlist. Any other module (local or +// third-party) emits a require() that the sandbox rejects synchronously, aborting the +// preload before contextBridge.exposeInMainWorld runs and leaving window.paseoDesktop +// undefined. That regression (0.1.108, #2103) is what this test guards against. +const SANDBOX_ALLOWLIST = new Set(["electron"]); + +const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.ts"); + +// Collect every module specifier that survives to emitted JavaScript as a runtime load. +// Type-only imports/exports are erased by tsc and are therefore ignored. +function runtimeModuleSpecifiers(source: string): string[] { + const sourceFile = ts.createSourceFile( + "preload.ts", + source, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ); + const specifiers: string[] = []; + + const record = (node: ts.Expression | undefined): void => { + if (node && ts.isStringLiteralLike(node)) { + specifiers.push(node.text); + } + }; + + const isTypeOnlyImport = (node: ts.ImportDeclaration): boolean => { + const clause = node.importClause; + if (!clause) { + // Side-effect import: `import "./x.js"` — always a runtime load. + return false; + } + if (clause.isTypeOnly) { + return true; + } + const bindings = clause.namedBindings; + // `import { type A, type B } from "x"` erases entirely; a default or namespace + // binding, or any value-named binding, keeps the module at runtime. + return ( + clause.name === undefined && + bindings !== undefined && + ts.isNamedImports(bindings) && + bindings.elements.length > 0 && + bindings.elements.every((element) => element.isTypeOnly) + ); + }; + + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + if (!isTypeOnlyImport(node)) { + record(node.moduleSpecifier); + } + } else if (ts.isExportDeclaration(node) && !node.isTypeOnly && node.moduleSpecifier) { + record(node.moduleSpecifier); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + record(node.moduleReference.expression); + } else if (ts.isCallExpression(node)) { + const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require"; + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + if (isRequire || isDynamicImport) { + record(node.arguments[0]); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return specifiers; +} + +describe("preload sandbox safety", () => { + it("only loads Electron's sandbox allowlist at runtime", () => { + const source = readFileSync(preloadPath, "utf8"); + const disallowed = runtimeModuleSpecifiers(source).filter( + (specifier) => !SANDBOX_ALLOWLIST.has(specifier), + ); + expect(disallowed).toEqual([]); + }); + + it("inlines the browser profile partition instead of importing it", () => { + const source = readFileSync(preloadPath, "utf8"); + const match = source.match(/const\s+PASEO_BROWSER_PROFILE_PARTITION\s*=\s*"([^"]+)"/); + expect( + match, + "PASEO_BROWSER_PROFILE_PARTITION not found as a double-quoted string literal in preload.ts", + ).not.toBeNull(); + expect(match![1]).toBe(PASEO_BROWSER_PROFILE_PARTITION); + }); +}); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e01a38e66..c402abb27 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,5 +1,12 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; -import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js"; + +// This preload runs in Electron's sandbox and is tsc-compiled (not bundled), so it MUST +// NOT emit any runtime module load other than "electron" — a require() of a local or +// third-party module throws and aborts the preload before exposeInMainWorld runs, leaving +// window.paseoDesktop undefined (the 0.1.108 regression, #2103). Keep this literal in sync +// with PASEO_BROWSER_PROFILE_PARTITION in features/browser-profile.ts; preload-sandbox.test.ts +// guards both the no-local-import rule and this drift. Type-only imports are fine (erased at emit). +const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser"; type EventHandler = (payload: unknown) => void;