spike: QuickJS-WASM sandbox marshaling cost harness#10072
Draft
jackkav wants to merge 1 commit into
Draft
Conversation
Standalone, runnable spike measuring the boundary-crossing cost of running pre/post-request scripts inside a QuickJS-WASM engine instead of the current same-realm AsyncFunction model. Compares two architectures on the same representative script: proxying the live host object (today's pass-by-reference) vs. bulk-copying state in and rebuilding the pm/insomnia API inside the sandbox. Documents the async bridge finding (asyncify collides with user await chains; VM-native promises + a driver loop is the robust pattern). Not wired into the app; see README for results and implications.
✅ Circular References ReportGenerated at: 2026-06-12T09:33:52.409Z Summary
Click to view all circular references in PR (9)Click to view all circular references in base branch (9)Analysis✅ No Change: This PR does not introduce or remove any circular references. This report was generated automatically by comparing against the |
Contributor
|
The approach 2 seems better to me, there could be some objects that cannot be proxied. And tackling |
jackkav
added a commit
that referenced
this pull request
Jul 6, 2026
Behind a new `templateTagSandboxEnabled` setting (default off), route plugin template-tag execution through a QuickJS-WASM sandbox instead of invoking the plugin's `run()` directly in the main process. Approach (per PR #10072): bulk-copy render state into the sandbox as JSON, rebuild the plugin `context` API in pure JS inside the sandbox, and bridge only async work back to the host via the existing `pluginToMainAPI` handlers. `node:crypto` is exposed as synchronous host functions so `require('crypto')` works without a sync/async mismatch. - templating/sandbox/: quickjs-runtime, marshal, host-bridge, in-sandbox-bootstrap, plugin-tag-sandbox (+ parity tests vs in-process tags and node:crypto) - main/templating-worker-database.ts: route execute handlers through the sandbox when the flag is on; legacy path unchanged otherwise - esbuild: keep quickjs-emscripten external so its .wasm resolves at runtime - settings + scripting-settings UI toggle - examples/insomnia-plugin-sandbox-demo: manual E2E fixture Scope: template tags only; sandbox runs in main. require shim covers path + crypto (other modules throw a clear error — follow-up work).
jackkav
added a commit
that referenced
this pull request
Jul 6, 2026
…e canary (#10207) * feat(templating): PoC run plugin template tags in a QuickJS-WASM sandbox Behind a new `templateTagSandboxEnabled` setting (default off), route plugin template-tag execution through a QuickJS-WASM sandbox instead of invoking the plugin's `run()` directly in the main process. Approach (per PR #10072): bulk-copy render state into the sandbox as JSON, rebuild the plugin `context` API in pure JS inside the sandbox, and bridge only async work back to the host via the existing `pluginToMainAPI` handlers. `node:crypto` is exposed as synchronous host functions so `require('crypto')` works without a sync/async mismatch. - templating/sandbox/: quickjs-runtime, marshal, host-bridge, in-sandbox-bootstrap, plugin-tag-sandbox (+ parity tests vs in-process tags and node:crypto) - main/templating-worker-database.ts: route execute handlers through the sandbox when the flag is on; legacy path unchanged otherwise - esbuild: keep quickjs-emscripten external so its .wasm resolves at runtime - settings + scripting-settings UI toggle - examples/insomnia-plugin-sandbox-demo: manual E2E fixture Scope: template tags only; sandbox runs in main. require shim covers path + crypto (other modules throw a clear error — follow-up work). * test(smoke): e2e canary for the template-tag sandbox flag Installs an inline probe plugin, renders its tags via the tag editor Live Preview, and asserts the execution path flips main-process -> sandbox when templateTagSandboxEnabled is toggled in Preferences > Scripting, with a require('crypto') sha256 workload staying byte-identical across both paths. * test(sandbox): suppress hardcoded-hmac-key semgrep finding on parity fixture The HMAC key is a test vector for sandbox-vs-node:crypto parity, not a credential; rename it to make that self-evident and add the repo-standard nosemgrep suppression. * fix(templating): contain sandbox plugin entry resolution to the plugin directory Reject a package.json "main" that resolves outside the plugin's own folder and bundled-plugin names that look like paths, so the sandbox source loader cannot be steered into reading arbitrary files. * fix(review): inline nosemgrep placement, plugin-load error context, cross-arch-safe canary - Move the hardcoded-hmac-key suppression onto the flagged line (line-above placement was not honored by the scanner). - Wrap getPluginEntrySource failures with the plugin name for diagnosability. - Derive the canary's expected arch from the Electron main process instead of the Playwright runner so cross-arch setups can't flake the assertion. * sec(templating): QuickJS template-tag sandbox additions (#10209) * fix(sandbox): enforce timeout on synchronous plugin loops QuickJS's executePendingJobs() blocks the host thread until a synchronous call returns, so the wall-clock deadline in drivePromiseToString was never checked during a tight sync loop in plugin code, hanging the Electron main process indefinitely. Add a QuickJS interrupt handler, which is polled during synchronous execution, to enforce the deadline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): clamp crypto.randomBytes size to prevent OOM hostCrypto.randomBytes(size) passed the sandboxed number straight to Node's crypto.randomBytes with no upper bound, letting a plugin request a multi-GB allocation (e.g. crypto.randomBytes(2 ** 31)) and crash the host process. Clamp to 64KB before the call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): cap QuickJS heap to prevent unbounded allocation QuickJS.newContext() had no memory limit, so a plugin allocating without bound could exhaust the WASM heap and crash the host process. Set a 32MB ceiling via ctx.runtime.setMemoryLimit(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): resolve symlinks before validating plugin entry path getPluginEntrySource's containment check compared raw path strings, so a plugin directory with a symlinked entry (e.g. index.js -> ../../../etc/secret) passed the check while fs.readFileSync followed the symlink and read the out-of-directory target. Re-run the check against fs.realpathSync'd paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): close util.render sandbox escape context.util.render() bridged to the shared render() pipeline, whose Liquid engine dispatches any registered tag's real run() directly, in-process, regardless of templateTagSandboxEnabled. A sandboxed plugin could hand it a string containing "{% anyTag %}" (including its own tag) and have that tag execute completely unsandboxed. Verified with a PoC that reached child_process execution from inside a plugin tag with no require() or Node access. util.render is now restricted to plain {{ variable }} interpolation (the only real existing use, confirmed against all built-in tag call sites) via a second Liquid engine with no tags registered; {% tag %} syntax now fails to parse instead of dispatching. Default render() behavior is unchanged for every other caller. Also drops the dead renderDepth field's misleading doc comment: within one sandboxed execution the envelope's renderDepth is always 0, so depth could never exceed 1 regardless of enforcement — it couldn't have caught this recursion anyway. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sandbox): fixed linting issue * fix(sandbox): fixed linting issue --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: kwburns-kong <kyle.burns@konghq.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
A throwaway, runnable spike that measures the marshaling cost of moving pre/post-request scripts from the current same-realm
AsyncFunctionmodel (run-script.ts/sandbox.ts) into a separate QuickJS-WASM engine.Motivation: QuickJS-WASM is the most portable real-isolation option for sandboxing scripts/plugins across both the Electron environment (renderer / main / UtilityProcess) and the
insoNode CLI — one.wasm, no native rebuild, novm2. The open question was how expensive the WASM boundary is once you can no longer pass the liveInsomniaObjectby reference. This spike answers it with numbers.Not wired into the app. It lives under
packages/insomnia/src/scripting/quickjs-spike/and runs standalone so the results don't depend on the monorepo.What it does
Runs the same representative pre-request script (env get/set loops, header mutation,
console.log, and an awaitedinsomnia.sendRequestdoing real host async I/O) through two architectures and counts/times every boundary crossing:environment.get/set, header add, log, sendRequest crosses the boundary.pm/insomniaAPI inside the sandbox, bridge only what must escape (console + async sendRequest), copy mutated state out.Results (M-series, QuickJS 0.32, non-asyncify variant)
Fidelity passes both ways (awaited
sendRequestwriteslastStatus=200back; header added).Key findings
InsomniaObjectmethod-by-method. Serialize thetoObject()surface in once, rebuild the API in-sandbox over plain state, copy out once. Bulk JSON of a 7 KB payload is ~0.1 ms in / ~0.5 ms out.newAsyncifiedFunctiononly drives host calls on the synchronous eval path; a host call reached from a userawaitchain (every pm script) crashes the job pump. The robust pattern is VM-native promises (ctx.newPromise()) + a host driver loop, which also works with the smaller non-asyncify WASM.__bridgeReset__/__bridgeSettle__async-task monitor.Reviewer notes
package.json. To run:npm i quickjs-emscriptenin a scratch dir andnode harness.mjs(see the README).toObject()as the serialize/merge contract; onlysendRequest/console/vault need host bridges).