fix(desktop): stop sandboxed preload from requiring a local module (#2111)

* fix(desktop): stop sandboxed preload from requiring a local module

The preload imported PASEO_BROWSER_PROFILE_PARTITION from the local module
./features/browser-profile.js. The preload runs in Electron's sandbox and is
tsc-compiled (not bundled), so that import emits require("./features/
browser-profile.js") at the top of preload.js. A sandboxed preload cannot
require a local module, so the require throws before
contextBridge.exposeInMainWorld runs. window.paseoDesktop is then undefined,
the app no longer detects itself as the desktop host, and it never starts the
built-in daemon (users had to run `paseo start` by hand).

The import was added in 0.1.108; 0.1.107 is clean, which matches the reports
that downgrading fixes it. See #2103.

Inline the one value the preload needs (the partition string) so it no longer
loads any local module, and keep browser-profile.ts as the source of truth for
the main process. Add preload-sandbox.test.ts, which parses preload.ts with the
TypeScript compiler API and asserts the only runtime module load is "electron"
(covering value imports, side-effect imports, re-exports, require, dynamic
import, and import-equals; type-only imports are ignored), plus a drift check
that the inlined literal matches the canonical constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(desktop): clearer preload drift-check failure message

Assert the inlined PASEO_BROWSER_PROFILE_PARTITION is present before comparing its value, so a missing/renamed constant reports "not found" instead of a misleading value-drift mismatch (Greptile feedback on #2111).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Christoph Leiter
2026-07-16 09:39:51 +02:00
committed by GitHub
parent 8554b94cdb
commit 64c819efeb
2 changed files with 107 additions and 1 deletions

View File

@@ -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);
});
});

View File

@@ -1,5 +1,12 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"; 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; type EventHandler = (payload: unknown) => void;