* 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.
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 off →
hello | ran in: main-process | arch via bridge: <arch> - Sandbox flag on →
hello | 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 __hostBridge → pluginToMainAPI['nodeOS'] and back.
Install (dev)
- Run the app:
npm run dev(repo root). - Preferences → Plugins → Reveal Plugins Folder.
- Copy this
insomnia-plugin-sandbox-demofolder into that directory. - Click Reload Plugins.
- Preferences → Scripting → toggle Run template tags in sandbox (experimental).
- In a request URL/header, insert the
Sandbox Probetemplate 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.