Skip to content

spike: QuickJS-WASM sandbox marshaling cost harness#10072

Draft
jackkav wants to merge 1 commit into
developfrom
claude/determined-vaughan-e432f9
Draft

spike: QuickJS-WASM sandbox marshaling cost harness#10072
jackkav wants to merge 1 commit into
developfrom
claude/determined-vaughan-e432f9

Conversation

@jackkav

@jackkav jackkav commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Overview

A throwaway, runnable spike that measures the marshaling cost of moving pre/post-request scripts from the current same-realm AsyncFunction model (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 inso Node CLI — one .wasm, no native rebuild, no vm2. The open question was how expensive the WASM boundary is once you can no longer pass the live InsomniaObject by 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 awaited insomnia.sendRequest doing real host async I/O) through two architectures and counts/times every boundary crossing:

  • A — proxy the live host object (the natural port of today's pass-by-reference): every environment.get/set, header add, log, sendRequest crosses the boundary.
  • B — bulk-copy state in, rebuild the pm/insomnia API inside the sandbox, bridge only what must escape (console + async sendRequest), copy mutated state out.

Results (M-series, QuickJS 0.32, non-asyncify variant)

env vars: 50    per-crossing ≈ 1473 ns
A  proxy live object   crossings= 2005  run=13.07 ms
B  bulk-copy + bridge  crossings=    4  run= 1.92 ms   → 501× fewer crossings, 6.8× faster

env vars: 500   per-crossing ≈ 1229 ns
A  proxy live object   crossings= 2005  run= 5.70 ms
B  bulk-copy + bridge  crossings=    4  run= 1.97 ms   → 501× fewer crossings, 2.9× faster

Fidelity passes both ways (awaited sendRequest writes lastStatus=200 back; header added).

Key findings

  1. A crossing is cheap (~1.2–1.5 µs); cost is dominated by crossing count, which is an architecture choice, not a QuickJS limit.
  2. Don't proxy the live InsomniaObject method-by-method. Serialize the toObject() 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.
  3. Async is the real work, and asyncify is a trap: newAsyncifiedFunction only drives host calls on the synchronous eval path; a host call reached from a user await chain (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.
  4. That driver loop is the QuickJS analogue of the existing __bridgeReset__/__bridgeSettle__ async-task monitor.

Reviewer notes

  • Spike code only — no production wiring, no dependency added to any workspace package.json. To run: npm i quickjs-emscripten in a scratch dir and node harness.mjs (see the README).
  • The README documents implications for an eventual real port (reuse toObject() as the serialize/merge contract; only sendRequest/console/vault need host bridges).

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.
@github-actions

Copy link
Copy Markdown

✅ Circular References Report

Generated at: 2026-06-12T09:33:52.409Z
Status: ✅ NO CHANGE

Summary

Metric Base (develop) PR Change
Total Circular References 9 9 0 (0.00%)
Click to view all circular references in PR (9)
insomnia-inso/src/db/models/types.ts -> insomnia-inso/src/db/types.ts
insomnia/src/main/prompt-bridge.ts -> insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/network/network.ts -> insomnia-scripting-environment/src/objects/index.ts -> insomnia-scripting-environment/src/objects/collection.ts -> insomnia-scripting-environment/src/objects/response.ts
insomnia/src/network/network.ts -> insomnia/src/common/render.ts
insomnia/src/ui/components/settings/import-export.tsx -> insomnia/src/ui/components/modals/export-requests-modal.tsx
insomnia/src/ui/components/tabs/tab-list.tsx -> insomnia/src/ui/components/tabs/tab.tsx
insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx
insomnia/src/ui/components/viewers/response-viewer.tsx -> insomnia/src/ui/components/viewers/response-multipart-viewer.tsx
Click to view all circular references in base branch (9)
insomnia-inso/src/db/models/types.ts -> insomnia-inso/src/db/types.ts
insomnia/src/main/prompt-bridge.ts -> insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/main/window-utils.ts -> insomnia/src/main/plugin-window.ts
insomnia/src/network/network.ts -> insomnia-scripting-environment/src/objects/index.ts -> insomnia-scripting-environment/src/objects/collection.ts -> insomnia-scripting-environment/src/objects/response.ts
insomnia/src/network/network.ts -> insomnia/src/common/render.ts
insomnia/src/ui/components/settings/import-export.tsx -> insomnia/src/ui/components/modals/export-requests-modal.tsx
insomnia/src/ui/components/tabs/tab-list.tsx -> insomnia/src/ui/components/tabs/tab.tsx
insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx
insomnia/src/ui/components/viewers/response-viewer.tsx -> insomnia/src/ui/components/viewers/response-multipart-viewer.tsx

Analysis

No Change: This PR does not introduce or remove any circular references.


This report was generated automatically by comparing against the develop branch.

@ihexxa

ihexxa commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

The approach 2 seems better to me, there could be some objects that cannot be proxied. And tackling require could be considered at some point. Really cool as it looks very compact and also covers benchmarks.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants