* 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>
100 lines
3.7 KiB
TypeScript
100 lines
3.7 KiB
TypeScript
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);
|
|
});
|
|
});
|