Files
insomnia/examples/insomnia-plugin-sandbox-demo
Jack Kavanagh 6c021150f2 feat(templating): multi-file plugins via in-sandbox module map (M4) (#10256)
* feat(templating): multi-file plugins via in-sandbox module map (M4)

esbuild is dev-only, so instead of bundling plugins at load time this reads the
plugin's own source files into a module map (readPluginModuleMap, host-side)
and teaches the in-sandbox require to resolve RELATIVE specifiers from that map
(./util, ../x, ./dir -> ./dir/index.js) with a CommonJS cache. Bare specifiers
still route through the grant-gated registry.

Security (the poison guarantee): the host walker reads ONLY within the plugin
directory — node_modules and dot-dirs are skipped, symlink-escapes dropped,
file count/size bounded — so a plugin's own node_modules is never consulted and
a bare require('uuid') always binds the vetted registry copy, never a plugin's
substitute. Plugin source now travels as envelope DATA (moduleFiles) compiled by
the loader via new Function; no host-side eval of plugin code. Single-file
callers keep working via a synthesized one-entry map.

Tests: multi-file-plugins.test.ts (7: relative/nested/dir-index resolution,
hybrid bare-uuid, CJS cache, not-found, syntax-error propagation, bare-vs-map
shadow) + readPluginModuleMap tests (map shape, node_modules-excluded poison
guarantee, custom main, entry-escape). E2E: multi-file resolution + poison
(fake node_modules/uuid ignored). Demo converted to multi-file
(require('./lib/greeting')). PERMISSIONS.md documents multi-file + the
require-vetted-libs-inside-run note.

No esbuild runtime dependency added.

* fix(templating): tighten M4 module-map byte guard and relative-path escape

Address review feedback on the multi-file plugin loader:

- readPluginModuleMap counted source.length (UTF-16 code units), which
  undercounts multi-byte UTF-8 payloads against MAX_PLUGIN_MODULE_BYTES, and
  the custom-"main" entry fallback was added to the map without counting its
  bytes at all. Count Buffer.byteLength(...'utf8') via a shared addModule
  helper used by both the walk and the entry fallback.

- __normalizeKey clamped above-root '..' traversal back into the plugin root,
  so require('../../util') from a shallow dir could masquerade as an in-plugin
  module. Return null for above-root traversal (unresolvable), matching Node
  semantics; __resolveRelative propagates it as MODULE_NOT_FOUND.
2026-07-17 09:44:17 -04:00
..

insomnia-plugin-sandbox-demo

Manual test fixture for the QuickJS template-tag sandbox (Milestone 2).

The sandboxprobe tag reports where it executed and exercises an async host bridge:

  • Sandbox flag offhello | ran in: main-process | arch via bridge: <arch>
  • Sandbox flag onhello | ran in: sandbox | arch via bridge: <arch>

ran in flips because the sandbox defines the INSOMNIA_TEMPLATE_SANDBOX marker global, which the legacy main-process path lacks. (process used to be the signal, but since M2 the sandbox provides a process stub too, so a dedicated marker is used instead.) arch via bridge proves context.util.nodeOS() round-tripped through __hostBridgepluginToMainAPI['nodeOS'] and back.

Install (dev)

  1. Run the app: npm run dev (repo root).
  2. Preferences → Plugins → Reveal Plugins Folder.
  3. Copy this insomnia-plugin-sandbox-demo folder into that directory.
  4. Click Reload Plugins.
  5. Preferences → Scripting → toggle Run template tags in sandbox (experimental).
  6. In a request URL/header, insert the Sandbox Probe template tag (or type {% sandboxprobe 'hi' %}), and watch the preview change as you toggle the flag.

The requireprobe tag demos the manifest-gated module registry (M1): {% requireprobe 'path' %} renders a/b (baseline grant, curated registry implementation), while {% requireprobe 'fs' %} or any npm package fails with Module 'X' not permitted by manifest. A granted-but-unshipped module would fail with Module 'X' not available in sandbox. Registry coverage grows in M2/M3; relative files (require('./util')) arrive with plugin pre-bundling (M4).

The eventsprobe tag demos a manifest-declared grant (C3): this plugin's package.json declares insomnia.permissions.modules: ["events"], so {% eventsprobe %} renders events-ok. A plugin that did not declare events would get Module 'events' not permitted by manifest. Preferences → Plugins shows each plugin's declared permissions (this one lists modules: events).

The stdlibprobe tag demos the ambient sandbox globals (M2) — Buffer, URL/URLSearchParams, a frozen process stub, and Web-Crypto crypto.getRandomValues/crypto.subtle. These are always present (not manifest-gated) as pure-JS or host-backed safe equivalents, and render identically to the legacy main-process path: {% stdlibprobe 'buffer' %}, {% stdlibprobe 'url' %}, {% stdlibprobe 'platform' %}.

The capabilityprobe tag demos manifest-gated host capabilities (C1): this plugin declares insomnia.permissions.capabilities: ["storage"], so {% capabilityprobe %} completes a context.store set/get round-trip and renders storage-ok. A plugin that did not declare storage would get Capability 'storage' not granted — add it to insomnia.permissions.capabilities. Baseline capabilities (render, models.read, util, crypto) need no declaration; network / storage / fs-read / app do. credentials is reserved for first-party bundle plugins and can't be granted to a community plugin even if declared (it's above the template-tag surface's ceiling — see PERMISSIONS.md). A plugin that declares no manifest and reaches for a non-baseline module gets a one-time migration notification naming the grant to add.