Files
insomnia/packages/insomnia-data/node-src/services/response.ts
Jack Kavanagh 5ff4186a79 feat(templating): (H1) route user-plugin response hooks through the sandbox (PR 10b) (#10286)
* feat(templating): (H1) in-sandbox response-hook API + marshaling

First increment of PR 10b (response hooks). Adds the sandbox-side machinery to
run a user plugin's response hook, without yet wiring the network adapters:

- __buildResponseApi(resp): faithful ES5 rebuild of plugins/context/response.ts
  getters (status/headers/time, case-insensitive getHeader returning
  string|string[]|null). getBody() bridges to the existing response.getBodyBuffer
  path; getBodyStream() throws (a Node Readable can't cross the sandbox); setBody()
  base64-encodes the bytes over a new response.setBody bridge path (no fs in the
  sandbox) and updates bytesContent locally.
- __invokeHook: for response hooks, attaches context.response and a READ-ONLY
  context.request (matches pluginRequest.init(..., true)), and marshals both the
  request and response back out.
- host-bridge: response.setBody mapped at baseline (models.read, grouped with the
  other response ops) — a bounded write to the host-set bodyPath, matching the
  ungated in-process behavior; not a general fs write.

Unit test drives a response hook that reads status/headers, reads the body via
the bridge, and rewrites it via setBody (asserting the base64 round-trip and the
read-only request). Host runner + adapter integration + e2e are the next increment.

* feat(templating): (H1) route user-plugin response hooks through the sandbox

Wires the response-hook sandbox core into both hook paths so a user plugin's
response hook runs in QuickJS (bundle plugins and the flag-off path unchanged).

- templating-worker-database: runResponseHookInSandbox + the
  plugin.runUserResponseHook handler; the response.setBody bridge handler
  (base64-decode -> fs.writeFileSync) guarded to the responses directory as
  defense in depth (bodyPath is host-set); pickHookResponseFields.
- network-adapter.node.ts (main/CLI): runs the response hook in the sandbox for
  user plugins, merging the returned response fields onto newResponse.
- invoke-method.ts (plugin window): reaches the same runner over the protocol.

E2E: a user plugin response hook rewrites the body via setBody; the echo
response pane shows the rewrite. Flag off it reports ranin-mainprocess (control),
flag on ranin-sandboxed. Completes H1 (request hooks in PR 10a).

* fix(templating): (H1) revive bridged Buffer in response getBody + reject empty setBody

Addresses Copilot review on #10286:
- in-sandbox-bootstrap.ts: context.response.getBody() now revives the JSON-marshaled
  Buffer shape ({ type: 'Buffer', data: [...] }) back into a (shimmed) Buffer, so hooks
  consume it exactly like the in-process response API instead of a plain object.
- templating-worker-database.ts: response.setBody rejects a missing/non-string bodyBase64
  (TypeError) instead of defaulting to '' — a write primitive must not silently truncate
  the body to zero bytes. An empty string stays a valid empty body.
- sandbox-hooks.test.ts: the getBodyBuffer stub now returns the real marshaled Buffer
  shape and the hook decodes with toString('utf8'), exercising the revive end-to-end.
2026-07-24 10:57:40 -04:00

80 lines
2.6 KiB
TypeScript

import type { Response } from 'insomnia-data';
import { database as db, models } from 'insomnia-data';
import * as requestHelpers from './helpers/request-operations';
import * as requestVersionService from './request-version';
import * as settingsService from './settings';
const { type } = models.response;
export function getById(id: string) {
return db.findOne<Response>(type, { _id: id });
}
// Finds whichever response already owns a given on-disk body file, to verify ownership before overwrite.
export function getByBodyPath(bodyPath: string) {
return db.findOne<Response>(type, { bodyPath });
}
export function findByParentId(parentId: string) {
return db.find<Response>(type, { parentId: parentId });
}
export async function all() {
return db.find<Response>(type);
}
export async function getLatestForRequestId(
requestId: string,
environmentId: string | null,
): Promise<Response | undefined> {
// Filter responses by environment if setting is enabled
const shouldFilter = (await settingsService.get()).filterResponsesByEnv;
const response = await db.findOne<Response>(
type,
{
parentId: requestId,
...(shouldFilter ? { environmentId } : {}),
},
{ modified: -1 },
);
return response;
}
export async function create(patch: Partial<Response> = {}, maxResponses = 20): Promise<Response> {
if (!patch.parentId) {
console.log('[db] Attempted to create response without `parentId`', patch);
throw new Error('New Response missing `parentId`');
}
const { parentId } = patch;
// Create request version snapshot
const request = await requestHelpers.getRequestById(parentId);
const requestVersion = request ? await requestVersionService.create(request) : null;
patch.requestVersionId = requestVersion ? requestVersion._id : null;
// Filter responses by environment if setting is enabled
const settings = await settingsService.get();
const shouldQueryByEnvId = 'environmentId' in patch && settings.filterResponsesByEnv;
const query = {
parentId,
...(shouldQueryByEnvId ? { environmentId: patch.environmentId } : {}),
};
// Delete all other responses before creating the new one
const responsesToShow = Math.max(1, maxResponses);
const allResponses = await db.find<Response>(type, query, { modified: -1 }, responsesToShow);
const recentIds = allResponses.map(r => r._id);
// Remove all that were in the last query, except the first `maxResponses` IDs
await db.removeWhere(type, {
...query,
_id: {
$nin: recentIds,
},
});
// Actually create the new response
return db.docCreate(type, patch);
}