run extensions in a quickjs wasm sandbox

replaces new Function() in the main window; per-script and per-call
interrupt deadlines, memory and stack limits, graceful skip if the
wasm module fails to load. csp script-src drops 'unsafe-eval' for
'wasm-unsafe-eval'.
This commit is contained in:
Horizontal42 2026-07-29 14:38:28 +03:00
parent 5c4f2db2d2
commit b0f6e9e89b
11 changed files with 347 additions and 65 deletions

View file

@ -26,7 +26,8 @@ src/
app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd
storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files against the engine
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API
i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches
src-tauri/
@ -263,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
## Tests
`npm test` runs 152 vitest cases: `src/engine/*.test.ts` covers every
`npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every
expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on

View file

@ -27,7 +27,8 @@ src/
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы через движок
extensions.ts запускает пользовательские .js-файлы в песочнице
QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск
src-tauri/
@ -267,7 +268,7 @@ src-tauri/
## Тесты
`npm test` запускает 152 vitest-кейса: `src/engine/*.test.ts` покрывает все
`npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по

View file

@ -130,7 +130,7 @@ auto-derived one) so the reference doesn't break when its first line changes.
## Extensions
> **Note:** extensions run as trusted local scripts with access to your files. Only load code you wrote or fully trust.
> **Note:** extensions run inside a WebAssembly sandbox — no file, network or app access, only the `numi` API below. A script that hangs is cut off; a script that fails is skipped.
Drop a `.js` file into the extensions folder (Settings → Extensions folder)
and restart. The API is compatible with Numi extensions:
@ -156,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash
npm install
npm test # engine tests (152 cases)
npm test # engine tests (259 cases)
npm run tauri dev # run the app
npm run tauri build # build the installer
```

View file

@ -125,7 +125,7 @@ AAPL в USD → $213.55
## Расширения
> **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете.
> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается.
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
API совместим с расширениями Numi:
@ -151,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash
npm install
npm test # тесты движка (152 кейса)
npm test # тесты движка (259 кейсов)
npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик
```

28
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
"@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
@ -21,7 +22,8 @@
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"decimal.js": "^10.4.3"
"decimal.js": "^10.4.3",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
@ -532,6 +534,21 @@
"node": ">=18"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-singlefile-browser-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-browser-release-sync/-/quickjs-singlefile-browser-release-sync-0.32.0.tgz",
"integrity": "sha512-Hfdl7rh8dzxNWFRiYAYNbhn0RMF1/tO6SMH2mUW0aTibqwaAtqPRbi4WkwaIDlhNz8Z4dksJi1Zjl1R54Jsc/Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -1709,6 +1726,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/rollup": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",

View file

@ -19,6 +19,7 @@
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
"@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
@ -27,7 +28,8 @@
"@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"decimal.js": "^10.4.3"
"decimal.js": "^10.4.3",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",

View file

@ -1,9 +1,9 @@
// WARNING: extensions run as trusted local code with full access to your files.
// Only load scripts you wrote yourself or fully trust.
// Summarum extension example.
// Drop .js files into this folder and restart the app.
// API: numi.setVariable, numi.addUnit, numi.addFunction.
//
// Scripts run in a WebAssembly sandbox: no files, no network, no app internals,
// and no console/setTimeout/fetch — only plain JS and the numi API above.
// A global variable usable on any sheet, e.g. `salary * vat`
// numi.setVariable("vat", { "double": 20 });

View file

@ -23,7 +23,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
}
},
"bundle": {

View file

@ -38,71 +38,170 @@ describe("extensions", () => {
describe("runExtensions", () => {
let consoleErrorSpy: any;
let consoleWarnSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
vi.resetModules();
vi.doUnmock("quickjs-emscripten-core");
});
it("runs scripts successfully using numi api", () => {
const scripts = [
{
name: "script1",
code: "numi.setVariable('var1', 10);",
},
{
name: "script2",
code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });",
},
];
runExtensions(mockEngine as SumEngine, scripts);
it("runs scripts successfully using numi api", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "script1", code: "numi.setVariable('var1', 10);" },
{ name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
expect(mockEngine.addUnit).toHaveBeenCalledWith({ id: "unit1", phrases: "u", baseUnitId: "USD", ratio: 1 });
expect(mockEngine.addUnit).toHaveBeenCalledWith({
id: "unit1",
phrases: "u",
baseUnitId: "USD",
ratio: 1,
});
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("swallows errors and logs to console.error", () => {
const scripts = [
{
name: "script1",
code: "throw new Error('test error');",
},
];
it("swallows errors and logs to console.error", async () => {
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
runExtensions(mockEngine as SumEngine, scripts);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"extension script1 failed:",
expect.any(Error)
);
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
});
it("continues running other scripts if one fails", () => {
const scripts = [
{
name: "script1",
code: "numi.setVariable('var1', 10);",
},
{
name: "script2",
code: "throw new Error('boom');",
},
{
name: "script3",
code: "numi.setVariable('var3', 30);",
},
];
runExtensions(mockEngine as SumEngine, scripts);
it("continues running other scripts if one fails", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "script1", code: "numi.setVariable('var1', 10);" },
{ name: "script2", code: "throw new Error('boom');" },
{ name: "script3", code: "numi.setVariable('var3', 30);" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
it("passes unit-carrying values through setVariable", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" });
});
it("rejects malformed api arguments as a script error", async () => {
await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]);
expect(mockEngine.setVariable).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable");
});
it("registers a function callable with one array of values", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "hyp",
code: "numi.addFunction({ id: 'hyp', phrases: 'hyp' }, function (values) { return { double: Math.hypot(values[0].double, values[1].double) }; });",
},
]);
expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function));
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
});
it("marshals unitId into the sandbox and back out", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "echo",
code: "numi.addFunction({ id: 'echo', phrases: 'echo' }, function (v) { return { double: v[0].double * 2, unitId: v[0].unitId }; });",
},
]);
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" });
});
it("has no host globals inside the sandbox", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined') throw new Error('leak');" },
{ name: "log", code: "console.log('hi');" },
]);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension log failed:");
expect(consoleErrorSpy.mock.calls[0][1].message).toMatch(/console/);
});
it("interrupts an infinite loop at load time", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "spin", code: "while (true) {}" },
{ name: "after", code: "numi.setVariable('ok', 1);" },
]);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:");
// the runtime survives the interrupt and keeps loading scripts
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
});
it("interrupts a hung function on every call, not just the first", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "spin",
code: "numi.addFunction({ id: 'spin', phrases: 'spin' }, function () { while (true) {} });",
},
{
name: "good",
code: "numi.addFunction({ id: 'good', phrases: 'good' }, function (v) { return v[0].double + 1; });",
},
]);
const spin = mockEngine.addFunction.mock.calls[0][1];
const good = mockEngine.addFunction.mock.calls[1][1];
expect(() => spin([])).toThrow(/spin/);
// deadline is re-armed per call: a second call still times out rather than
// failing instantly, and an unrelated function still works afterwards
expect(() => spin([])).toThrow(/spin/);
expect(good([{ double: 1 }])).toBe(2);
});
it("surfaces a bad return value as a function error", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" },
]);
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(() => fn([])).toThrow(/bad/);
});
it("skips extensions when the sandbox fails to load", async () => {
vi.resetModules();
vi.doMock("quickjs-emscripten-core", () => ({
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
}));
const { runExtensions: run } = await import("./extensions");
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
expect(mockEngine.setVariable).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(
"extensions disabled: QuickJS sandbox unavailable:",
expect.any(Error),
);
});
it("does not load the sandbox when there are no scripts", async () => {
await runExtensions(mockEngine as SumEngine, []);
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
});

View file

@ -1,5 +1,7 @@
// JS extensions runtime: numi.setVariable,
// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core";
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
interface ExtensionApi {
@ -16,12 +18,163 @@ export function makeApi(engine: SumEngine): ExtensionApi {
};
}
export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
const numi = makeApi(engine);
// 32 MB is orders of magnitude more than a sheet helper needs, and small enough
// that a runaway allocation fails inside the sandbox instead of the host heap.
const MEMORY_LIMIT = 32 * 1024 * 1024;
// 512 KB — unbounded recursion hits this and throws in the sandbox, never on the JS stack.
const STACK_SIZE = 512 * 1024;
// One-off cost at startup, so it can be generous.
const LOAD_TIMEOUT_MS = 1000;
// Registered functions are called synchronously from evaluateDocument, i.e. on
// every keystroke for every line that uses them — this budget must stay small.
const CALL_TIMEOUT_MS = 50;
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
function toExtValue(v: unknown, what: string): ExtensionValue | number {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (v && typeof v === "object") {
const o = v as { double?: unknown; unitId?: unknown };
if (typeof o.double === "number" && Number.isFinite(o.double)) {
return typeof o.unitId === "string" ? { double: o.double, unitId: o.unitId } : { double: o.double };
}
}
throw new Error(`${what}: expected a number or { double, unitId? }`);
}
function message(e: unknown): string {
if (e instanceof Error) return e.message;
if (e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string") {
return (e as { message: string }).message;
}
return String(e);
}
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
if (scripts.length === 0) return;
try {
await load(engine, scripts);
} catch (e) {
console.warn("extensions disabled: QuickJS sandbox unavailable:", e);
}
}
async function load(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
const vm: QuickJSContext = mod.newContext();
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
vm.runtime.setMaxStackSize(STACK_SIZE);
// Armed before every entry into the sandbox and disarmed after, so a hung script
// at load time and a hung function call on keystroke N are both bounded.
let deadline = Infinity;
vm.runtime.setInterruptHandler(() => now() > deadline);
const api = makeApi(engine);
const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => {
// extension-sample.js convention: the callback takes one array of values.
const arr = vm.newArray();
try {
values.forEach((v, i) => {
const o = vm.newObject();
const d = vm.newNumber(v.double);
vm.setProp(o, "double", d);
d.dispose();
if (v.unitId) {
const u = vm.newString(v.unitId);
vm.setProp(o, "unitId", u);
u.dispose();
}
vm.setProp(arr, i, o);
o.dispose();
});
let res;
deadline = now() + CALL_TIMEOUT_MS;
try {
res = vm.callFunction(fn, vm.undefined, [arr]);
} finally {
deadline = Infinity;
}
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(`${id}: ${message(err)}`);
}
const out = vm.dump(res.value);
res.value.dispose();
return toExtValue(out, id);
} finally {
arr.dispose();
}
};
const numi = vm.newObject();
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
const fn = vm.newFunction(name, (...args) => {
try {
impl(...args);
} catch (e) {
return { error: vm.newError(`numi.${name}: ${message(e)}`) };
}
});
vm.setProp(numi, name, fn);
fn.dispose();
};
define("setVariable", (nameHandle, valueHandle) => {
const name = vm.dump(nameHandle);
if (typeof name !== "string" || !name) throw new Error("expects a variable name");
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
});
define("addUnit", (specHandle) => {
const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string" || typeof spec.baseUnitId !== "string" || typeof spec.ratio !== "number") {
throw new Error("expects { id, phrases, baseUnitId, ratio }");
}
api.addUnit({
id: spec.id,
phrases: typeof spec.phrases === "string" ? spec.phrases : spec.id,
baseUnitId: spec.baseUnitId,
...(typeof spec.format === "string" ? { format: spec.format } : {}),
ratio: spec.ratio,
});
});
define("addFunction", (specHandle, fnHandle) => {
const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }");
if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function");
// outlives this callback, so it needs its own reference; the context is never disposed
const guestFn = fnHandle.dup();
const id = spec.id;
api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) =>
callGuest(id, guestFn, values),
);
});
vm.setProp(vm.global, "numi", numi);
numi.dispose();
for (const s of scripts) {
try {
const fn = new Function("numi", s.code);
fn(numi);
let res;
deadline = now() + LOAD_TIMEOUT_MS;
try {
res = vm.evalCode(s.code, s.name);
} finally {
deadline = Infinity;
}
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(message(err));
}
res.value.dispose();
} catch (e) {
console.error(`extension ${s.name} failed:`, e);
}

View file

@ -753,7 +753,7 @@ async function initDataAndEngine(): Promise<void> {
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
const scripts = await loadExtensionScripts();
runExtensions(engine, scripts);
await runExtensions(engine, scripts);
workspace = new Workspace(engine, () =>
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),