mirror of
https://github.com/Kong/insomnia.git
synced 2026-07-31 17:56:50 -04:00
Merge branch 'develop' into feat/utility-data-process
This commit is contained in:
149
package-lock.json
generated
149
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"packages/insomnia-scripting-environment"
|
||||
],
|
||||
"dependencies": {
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1942,6 +1942,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz",
|
||||
"integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
@@ -2056,35 +2066,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"node_modules/@electron/get/node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
@@ -3545,22 +3557,22 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@getinsomnia/node-libcurl": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.2.2.tgz",
|
||||
"integrity": "sha512-O4FTODtfwvLpnk+pyPHCIINCmu8Io+UaDDmcY2bU73VumAu6p/p/egQ6ndUOOId6OQ7V2jyAppZFgNb+fev/Uw==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.3.0.tgz",
|
||||
"integrity": "sha512-cmlwBjzqwiSsg6+wIwZPFTPwcyIbz9piIjV8IYy7HUwLhHtluB6oRF0GlkkyeFCnOdJ+kG3CGtdNgRPsW2ItVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "2.0.3",
|
||||
"consola": "^3.2.3",
|
||||
"env-paths": "2.2.0",
|
||||
"nan": "2.26.2",
|
||||
"nan": "2.28.0",
|
||||
"node-gyp": "12.2.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tslib": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 24.14.0"
|
||||
"node": ">= 24.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@getinsomnia/srp-js": {
|
||||
@@ -10695,17 +10707,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
|
||||
@@ -14733,22 +14734,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "41.0.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-41.0.3.tgz",
|
||||
"integrity": "sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==",
|
||||
"version": "43.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz",
|
||||
"integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder": {
|
||||
@@ -16450,43 +16451,6 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip/node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extsprintf": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
|
||||
@@ -17052,21 +17016,6 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
|
||||
@@ -21529,9 +21478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nano-css": {
|
||||
@@ -29184,7 +29133,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"@getinsomnia/srp-js": "1.0.0-alpha.1",
|
||||
"@grpc/grpc-js": "^1.14.4",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
@@ -29330,7 +29279,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "41.0.3",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder-squirrel-windows": "26.8.1",
|
||||
"electron-devtools-installer": "^3.2.1",
|
||||
@@ -29376,7 +29325,7 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"mocha": "^11.7.5",
|
||||
|
||||
19
package.json
19
package.json
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/kong/insomnia#readme",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"node": ">=24.18.0",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"workspaces": [
|
||||
@@ -31,8 +31,8 @@
|
||||
"type-check": "npm run type-check --workspaces --if-present",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"clean": "git clean -dfX",
|
||||
"install-libcurl-electron": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=electron --target=41.0.3",
|
||||
"install-libcurl-node": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=node --target=24.14.0",
|
||||
"install-libcurl-electron": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=electron --target=43.2.0",
|
||||
"install-libcurl-node": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=node --target=24.18.0",
|
||||
"inso-start": "npm start -w insomnia-inso",
|
||||
"inso-package": "npm run build -w insomnia-inso && npm run package -w insomnia-inso",
|
||||
"watch:app": "npm run build:electron-entrypoints -w insomnia && npm run start:dev-server -w insomnia",
|
||||
@@ -83,7 +83,18 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@getinsomnia/node-libcurl@3.3.0": true,
|
||||
"@scarf/scarf@1.4.0": true,
|
||||
"electron-winstaller@5.4.0": true,
|
||||
"esbuild@0.25.12": true,
|
||||
"esbuild@0.27.5": true,
|
||||
"fsevents@2.3.2": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"node-llama-cpp@3.18.1": true,
|
||||
"protobufjs@7.6.4": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"mocha": "^11.7.5",
|
||||
|
||||
@@ -1041,7 +1041,7 @@ export const go = (args?: string[]) => {
|
||||
program.parseAsync(scriptArgs).catch(logErrorAndExit);
|
||||
});
|
||||
|
||||
program.command('generate-docs').action(() => {
|
||||
program.command('generate-docs', { hidden: true }).action(() => {
|
||||
generateDocumentation(program);
|
||||
return process.exit(1);
|
||||
});
|
||||
|
||||
@@ -90,7 +90,8 @@ export function generateDocumentation(program: commander.Command): void {
|
||||
|
||||
const allCommands: any[] = [];
|
||||
|
||||
program.commands.forEach(command => {
|
||||
program.commands.forEach((command: commander.Command & { _hidden?: boolean }) => {
|
||||
if (command._hidden) return; // Skip hidden commands
|
||||
const commandData = generateCommandMarkdown(command, program.options, '');
|
||||
|
||||
allCommands.push({
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
type: collection.insomnia.rest/5.0
|
||||
schema_version: "5.1"
|
||||
name: Sandbox Action Collection
|
||||
meta:
|
||||
id: wrk_5a4a100000000000000000000000001
|
||||
created: 1751800000000
|
||||
modified: 1751800000000
|
||||
collection:
|
||||
- url: http://127.0.0.1:4010/echo
|
||||
name: Action Probe
|
||||
meta:
|
||||
id: req_5a4a100000000000000000000000002
|
||||
created: 1751800000001
|
||||
modified: 1751800000001
|
||||
isPrivate: false
|
||||
sortKey: -1751800000001
|
||||
method: GET
|
||||
body:
|
||||
mimeType: text/plain
|
||||
text: |
|
||||
{% actionreadback %}
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: text/plain
|
||||
@@ -492,6 +492,11 @@ export default function setup(app: Application) {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /v3/users/me/onboarding
|
||||
app.get('/v3/users/me/onboarding', (_req, res) => {
|
||||
res.status(200).send({ is_new_signup: false, first_request_treatment: null });
|
||||
});
|
||||
|
||||
app.get('/v1/organizations/:orgId/features', (_req, res) => {
|
||||
res.status(200).send(organizationFeatures);
|
||||
});
|
||||
|
||||
@@ -813,3 +813,98 @@ test('Response hook sandbox (H1): a user plugin response hook runs in the sandbo
|
||||
)
|
||||
.toContain('resphook-rewrote-body:ranin-sandboxed');
|
||||
});
|
||||
|
||||
test('Plugin action sandbox (A1): a user plugin request action runs in the sandbox and its side effect persists', async ({
|
||||
page,
|
||||
app,
|
||||
dataPath,
|
||||
insomnia,
|
||||
}) => {
|
||||
// Actions are fire-and-effect — nothing marshals back — so the canary rides the side effect: the
|
||||
// request action writes which path executed it (INSOMNIA_TEMPLATE_SANDBOX exists only in the
|
||||
// sandbox) plus whether it received the domain models, into plugin storage; a sibling template tag
|
||||
// reads that back out. The `storage` capability is declared so context.store is granted on both
|
||||
// paths. Canary values are chosen so neither is a substring of the other.
|
||||
writePlugin(
|
||||
dataPath,
|
||||
'insomnia-plugin-action-probe',
|
||||
{ permissions: { capabilities: ['storage'] } },
|
||||
`
|
||||
module.exports.requestActions = [
|
||||
{
|
||||
label: 'Probe Action',
|
||||
async action(context, data) {
|
||||
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
|
||||
await context.store.setItem('action_ran_in', ranIn);
|
||||
await context.store.setItem('action_req_id', (data && data.request && data.request._id) || 'no-request');
|
||||
},
|
||||
},
|
||||
];
|
||||
module.exports.templateTags = [
|
||||
{
|
||||
name: 'actionreadback', displayName: 'actionreadback', args: [],
|
||||
async run(context) {
|
||||
var ranIn = await context.store.getItem('action_ran_in');
|
||||
var reqId = await context.store.getItem('action_req_id');
|
||||
return (ranIn || 'unset') + '|' + (reqId === 'req-canary-7t3w' ? 'got-req' : 'no-req');
|
||||
},
|
||||
},
|
||||
];
|
||||
`,
|
||||
);
|
||||
|
||||
const fixture = await loadFixture('sandbox-action-collection.yaml');
|
||||
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
|
||||
await clearPluginToast(page);
|
||||
await page.getByLabel('Import').click();
|
||||
await page.locator('[data-test-id="import-from-clipboard"]').click();
|
||||
await page.getByRole('button', { name: 'Scan' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
|
||||
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
|
||||
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('Action Probe');
|
||||
await page.getByText('Body', { exact: true }).click();
|
||||
|
||||
// Fire the request action directly through the plugin bridge (the same call the request-actions
|
||||
// dropdown makes), passing domain models so we can prove they reach the action.
|
||||
const runAction = () =>
|
||||
page.evaluate(() =>
|
||||
(window as any).main.plugins.executeAction({
|
||||
type: 'request',
|
||||
pluginName: 'insomnia-plugin-action-probe',
|
||||
label: 'Probe Action',
|
||||
projectId: 'proj_action_probe',
|
||||
domainData: { request: { _id: 'req-canary-7t3w' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const readTagPreview = async (tagPrefix: string): Promise<string> => {
|
||||
await page.locator(`[data-template^="${tagPrefix}"]`).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
const preview = modal.getByLabel('Live Preview');
|
||||
await expect.soft(preview).not.toHaveValue('rendering...');
|
||||
const value = (await preview.inputValue()).trim();
|
||||
await modal.getByRole('button', { name: 'Done' }).click();
|
||||
await expect.soft(modal).toBeHidden();
|
||||
return value;
|
||||
};
|
||||
|
||||
// Flag OFF (default): the action runs in-process (control). Its write is observable via the tag,
|
||||
// and the domain models reached it.
|
||||
await runAction();
|
||||
await expect.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 }).toContain('ranin-mainprocess|got-req');
|
||||
|
||||
// Flag ON: the same action now runs in the QuickJS sandbox (its in-process fn is a throw-stub after
|
||||
// discovery, so a successful write proves routing). Poll run+readback so the just-toggled setting
|
||||
// has propagated to the main process.
|
||||
await enableSandbox(page);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await runAction();
|
||||
return readTagPreview('{% actionreadback');
|
||||
},
|
||||
{ timeout: 25_000 },
|
||||
)
|
||||
.toContain('ranin-sandboxed|got-req');
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@getinsomnia/node-libcurl": "3.2.2",
|
||||
"@getinsomnia/node-libcurl": "3.3.0",
|
||||
"@getinsomnia/srp-js": "1.0.0-alpha.1",
|
||||
"@grpc/grpc-js": "^1.14.4",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
@@ -202,7 +202,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "41.0.3",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder-squirrel-windows": "26.8.1",
|
||||
"electron-devtools-installer": "^3.2.1",
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
import * as templatingUtils from '~/common/templating/utils';
|
||||
import { setDefaultProtocol } from '~/common/utils/url/protocol';
|
||||
|
||||
import { getOrInheritAuthentication, getOrInheritHeaders } from '../network/network';
|
||||
import { getOrInheritAuthentication, getOrInheritHeaders, shouldSuppressUserAgent } from '../network/network';
|
||||
import { getRuntime } from '../runtimes';
|
||||
import { CONTENT_TYPE_GRAPHQL, JSON_ORDER_SEPARATOR } from './constants';
|
||||
import { database as db } from './database';
|
||||
@@ -581,6 +581,7 @@ export async function getRenderedRequestAndContext({
|
||||
const description = request.description;
|
||||
request.description = '';
|
||||
|
||||
const suppressUserAgent = shouldSuppressUserAgent({ request, requestGroups });
|
||||
request.headers = getOrInheritHeaders({ request, requestGroups });
|
||||
request.authentication = getOrInheritAuthentication({ request, requestGroups });
|
||||
// Render all request properties
|
||||
@@ -599,12 +600,6 @@ export async function getRenderedRequestAndContext({
|
||||
const renderedRequest = renderResult._request;
|
||||
const renderedCookieJar = renderResult._cookieJar;
|
||||
renderedRequest.description = await render(description, renderContext, null, 'keep');
|
||||
const userAgentHeaders = request.headers.filter(h => h.name.toLowerCase() === 'user-agent');
|
||||
const hasUserAgentHeader = userAgentHeaders.length > 0;
|
||||
const allUserAgentHeadersDisabled = hasUserAgentHeader && userAgentHeaders.every(h => h.disabled === true);
|
||||
// Suppress the default User-Agent when the request opts out via disableUserAgentHeader,
|
||||
// or when the user added their own User-Agent header(s) and disabled all of them.
|
||||
const suppressUserAgent = request.disableUserAgentHeader || allUserAgentHeadersDisabled;
|
||||
// Remove disabled params
|
||||
renderedRequest.parameters = renderedRequest.parameters.filter(p => !p.disabled);
|
||||
// Remove disabled headers
|
||||
|
||||
@@ -97,6 +97,7 @@ export type PluginToMainAPIPaths =
|
||||
| 'plugin.discoverUserPluginExports'
|
||||
| 'plugin.runUserRequestHook'
|
||||
| 'plugin.runUserResponseHook'
|
||||
| 'plugin.runUserAction'
|
||||
| 'response.setBody'
|
||||
| 'app.alert'
|
||||
| 'app.dialog'
|
||||
|
||||
@@ -114,6 +114,37 @@ describe('resolveDbByKey requires a valid protocol auth token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDbByKey answers CORS preflight and tags responses with CORS headers', () => {
|
||||
beforeEach(() => {
|
||||
_testOnlyResetTemplatingDbAuthToken();
|
||||
});
|
||||
|
||||
it('answers an OPTIONS preflight with 204 and CORS headers, without requiring a token', async () => {
|
||||
const { resolveDbByKey } = await import('../templating-worker-database');
|
||||
const req = new Request('insomnia-templating-worker-database://settings.get', {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://insomnia-app.local' },
|
||||
});
|
||||
const res = await resolveDbByKey(req);
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://insomnia-app.local');
|
||||
expect(res.headers.get('Access-Control-Allow-Headers')).toContain(TEMPLATING_DB_AUTH_HEADER);
|
||||
expect(res.headers.get('Access-Control-Allow-Methods')).toContain('OPTIONS');
|
||||
});
|
||||
|
||||
it('tags an authorized response with an Access-Control-Allow-Origin echoing the request Origin', async () => {
|
||||
const token = getOrCreateTemplatingDbAuthToken();
|
||||
const { resolveDbByKey } = await import('../templating-worker-database');
|
||||
const req = new Request('insomnia-templating-worker-database://settings.get', {
|
||||
method: 'POST',
|
||||
headers: { [TEMPLATING_DB_AUTH_HEADER]: token, Origin: 'https://insomnia-app.local' },
|
||||
body: '{}',
|
||||
});
|
||||
const res = await resolveDbByKey(req);
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://insomnia-app.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('protocol-dispatch handlers resolve `directory` from the trusted registry, not the request body', () => {
|
||||
let legitDir: string;
|
||||
let foreignDir: string;
|
||||
@@ -483,6 +514,19 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d
|
||||
expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body');
|
||||
});
|
||||
|
||||
it('rejects a bodyPath reached through a symlinked directory that resolves outside the responses directory', async () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-'));
|
||||
const linkedDir = path.join(responsesDir, 'linked');
|
||||
fs.symlinkSync(outsideDir, linkedDir, 'dir');
|
||||
const throughSymlink = path.join(linkedDir, 'escaped-body.txt');
|
||||
try {
|
||||
await runResponseHook(throughSymlink, 'req_1');
|
||||
expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Same check, reached by sending the path-normalization variant directly to response.setBody.
|
||||
it('rejects a path-normalization variant sent directly to response.setBody', async () => {
|
||||
const { services } = await import('insomnia-data');
|
||||
@@ -508,6 +552,34 @@ describe('plugin.runUserResponseHook: setBody cannot redirect its write onto a d
|
||||
expect(json.error).toMatch(/belongs to a different response/);
|
||||
expect(fs.readFileSync(otherBodyPath, 'utf8')).toBe('other-original-body');
|
||||
});
|
||||
|
||||
// Same check, reached by sending a bodyPath through a symlinked directory directly to response.setBody.
|
||||
it('rejects a bodyPath through a symlinked directory sent directly to response.setBody', async () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-outside-'));
|
||||
const linkedDir = path.join(responsesDir, 'linked');
|
||||
fs.symlinkSync(outsideDir, linkedDir, 'dir');
|
||||
const throughSymlink = path.join(linkedDir, 'escaped-body.txt');
|
||||
const token = getOrCreateTemplatingDbAuthToken();
|
||||
const { resolveDbByKey } = await import('../templating-worker-database');
|
||||
try {
|
||||
const req = new Request('insomnia-templating-worker-database://response.setbody', {
|
||||
method: 'POST',
|
||||
headers: { [TEMPLATING_DB_AUTH_HEADER]: token },
|
||||
body: JSON.stringify({
|
||||
bodyPath: throughSymlink,
|
||||
bodyBase64: Buffer.from('replacement-body', 'utf8').toString('base64'),
|
||||
parentId: 'req_1',
|
||||
}),
|
||||
});
|
||||
const res = await resolveDbByKey(req);
|
||||
expect(res.status).toBe(500);
|
||||
const json = await res.json();
|
||||
expect(json.error).toMatch(/escapes the responses directory/);
|
||||
expect(fs.existsSync(path.join(outsideDir, 'escaped-body.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverPluginExportsInSandbox strips dangerous JSON keys, symmetric with the hook path', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function registerInsomniaProtocols() {
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
corsEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -40,7 +41,7 @@ export async function registerInsomniaProtocols() {
|
||||
},
|
||||
{
|
||||
scheme: templatingWorkerDatabaseInterface,
|
||||
privileges: { secure: true, standard: true, supportFetchAPI: true },
|
||||
privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -33,11 +33,24 @@ import { isValidTemplatingDbAuthToken, TEMPLATING_DB_AUTH_HEADER } from './templ
|
||||
|
||||
const bundlePluginModuleMap: Record<string, Plugin['module']> = {};
|
||||
|
||||
const templatingDbCorsHeaders = (request: Request): Record<string, string> => ({
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') ?? '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': `${TEMPLATING_DB_AUTH_HEADER}, content-type`,
|
||||
'Vary': 'Origin',
|
||||
});
|
||||
|
||||
export const resolveDbByKey = async (request: Request) => {
|
||||
const cors = templatingDbCorsHeaders(request);
|
||||
// A CORS preflight never carries the auth header, so answer it before the token check.
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
// Reject before parsing the body, so an unauthenticated call never reaches a handler.
|
||||
if (!isValidTemplatingDbAuthToken(request.headers.get(TEMPLATING_DB_AUTH_HEADER))) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized: missing or invalid templating database auth token' }), {
|
||||
status: 401,
|
||||
headers: cors,
|
||||
});
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
@@ -45,7 +58,7 @@ export const resolveDbByKey = async (request: Request) => {
|
||||
try {
|
||||
// We expect this to throw if a db call returns undefined
|
||||
body = JSON.parse(await request.text(), stripDangerousKeysReviver);
|
||||
} catch {}
|
||||
} catch { }
|
||||
// url get normalized to lowercase, so we need to normalize the keys to lower case as well
|
||||
const withLowercasedKeys = Object.fromEntries(
|
||||
Object.entries(pluginToMainAPI).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
@@ -60,10 +73,10 @@ export const resolveDbByKey = async (request: Request) => {
|
||||
throw new TypeError(`No host bridge handler registered for "${urlHostLowerCase}"`);
|
||||
}
|
||||
const result = await handler(body);
|
||||
return new Response(JSON.stringify(result));
|
||||
return new Response(JSON.stringify(result), { headers: cors });
|
||||
} catch (err) {
|
||||
console.error(`Error resolving db by key ${urlHostLowerCase}:`, err);
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: cors });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -579,6 +592,47 @@ export const runResponseHookInSandbox = async (
|
||||
return result.response ?? hookResponse;
|
||||
};
|
||||
|
||||
// A1: run a user plugin's action (matching actionLabel in the actionKind list) inside the sandbox,
|
||||
// capability-gated. Actions are fire-and-effect — the plugin reads the copied-in domain models and
|
||||
// performs side effects only through the bridge — so nothing is marshaled back. Reuses the same
|
||||
// bridge/grants/crypto as tag and hook execution.
|
||||
export const runActionInSandbox = async (
|
||||
plugin: { directory: string; name: string; permissions?: { modules?: string[]; capabilities?: string[] } },
|
||||
actionKind: 'request' | 'requestGroup' | 'workspace' | 'document',
|
||||
actionLabel: string,
|
||||
actionDomainData: unknown,
|
||||
renderContext?: Record<string, any>,
|
||||
): Promise<void> => {
|
||||
const { runTagInSandbox } = await import('../templating/sandbox/plugin-tag-sandbox');
|
||||
const { resolveTemplateTagModules, resolveTemplateTagCapabilities } = await import(
|
||||
'../templating/sandbox/surface-profiles'
|
||||
);
|
||||
const grantedCapabilities = resolveTemplateTagCapabilities(plugin.permissions?.capabilities);
|
||||
const bridge = await buildSandboxBridge(grantedCapabilities);
|
||||
const { moduleFiles, entryModuleKey } = readPluginModuleMap({ directory: plugin.directory, name: plugin.name });
|
||||
await runTagInSandbox({
|
||||
tagName: '',
|
||||
bridge,
|
||||
hostCrypto: SANDBOX_HOST_CRYPTO,
|
||||
envelope: {
|
||||
args: [],
|
||||
context: renderContext || {},
|
||||
meta: {},
|
||||
renderPurpose: 'send',
|
||||
appInfo: { version: app.getVersion(), platform: process.platform, arch: process.arch },
|
||||
pluginName: plugin.name,
|
||||
renderDepth: 0,
|
||||
grantedModules: resolveTemplateTagModules(plugin.permissions?.modules),
|
||||
grantedCapabilities,
|
||||
moduleFiles,
|
||||
entryModuleKey,
|
||||
actionKind,
|
||||
actionLabel,
|
||||
actionDomainData,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// These are exposed to the templating worker and can be used by plugins from context.util.
|
||||
// Exported so tests can enumerate every registered path.
|
||||
export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => Promise<any>> = {
|
||||
@@ -653,6 +707,12 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error('response.setBody path escapes the responses directory');
|
||||
}
|
||||
// Re-check after resolving symlinks, mirroring getPluginEntrySource/readPluginModuleMap: a
|
||||
// symlinked directory entry under responsesDir could otherwise resolve outside it despite the
|
||||
// string-based check above passing.
|
||||
if (!isContainedIn(fs.realpathSync(responsesDir), fs.realpathSync(path.dirname(target)))) {
|
||||
throw new Error('response.setBody path escapes the responses directory');
|
||||
}
|
||||
fs.writeFileSync(target, Buffer.from(body.bodyBase64, 'base64'));
|
||||
return null;
|
||||
},
|
||||
@@ -910,6 +970,25 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
|
||||
body.renderContext,
|
||||
);
|
||||
},
|
||||
// A1: run a user plugin's action in the sandbox (plugin-window path reaches this over the
|
||||
// templating-worker-database protocol; the node runtime calls runActionInSandbox directly).
|
||||
'plugin.runUserAction': async (body: {
|
||||
plugin: { directory: string; name: string; permissions?: { modules?: string[]; capabilities?: string[] } };
|
||||
actionKind: 'request' | 'requestGroup' | 'workspace' | 'document';
|
||||
actionLabel: string;
|
||||
actionDomainData: unknown;
|
||||
renderContext?: Record<string, any>;
|
||||
}) => {
|
||||
const trusted = await resolveTrustedPlugin(body.plugin.name);
|
||||
await runActionInSandbox(
|
||||
{ ...body.plugin, directory: trusted.directory, permissions: trusted.permissions },
|
||||
body.actionKind,
|
||||
body.actionLabel,
|
||||
body.actionDomainData,
|
||||
body.renderContext,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
// execute a user-installed plugin tag with the given parameters, in the main process where
|
||||
// Node built-ins (e.g. crypto) the plugin requires are available.
|
||||
'plugin.executeUserPluginTag': async (body: {
|
||||
|
||||
@@ -1169,3 +1169,35 @@ describe('getOrInheritHeaders', () => {
|
||||
expect(networkUtils.getOrInheritHeaders({ request, requestGroups })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRenderedRequest suppressUserAgent', () => {
|
||||
it('suppresses default user-agent when a custom user-agent header is disabled', async () => {
|
||||
const workspace = await services.workspace.create();
|
||||
const request = Object.assign(models.request.init(), {
|
||||
_id: 'req_disabled_ua',
|
||||
parentId: workspace._id,
|
||||
url: 'http://localhost',
|
||||
headers: [{ name: 'User-Agent', value: 'custom-xx', disabled: true }],
|
||||
});
|
||||
|
||||
expect((await getRenderedRequest({ request })).suppressUserAgent).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses default user-agent when all inherited user-agent headers are disabled', async () => {
|
||||
const workspace = await services.workspace.create();
|
||||
const folder = await services.requestGroup.create({
|
||||
_id: 'fld_disabled_ua',
|
||||
name: 'Disabled UA Folder',
|
||||
parentId: workspace._id,
|
||||
metaSortKey: 0,
|
||||
headers: [{ name: 'User-Agent', value: 'custom-xx', disabled: true }],
|
||||
});
|
||||
const request = Object.assign(models.request.init(), {
|
||||
_id: 'req_inherited_disabled_ua',
|
||||
parentId: folder._id,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
|
||||
expect((await getRenderedRequest({ request })).suppressUserAgent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,24 @@ export function getOrInheritHeaders({
|
||||
.sort(ascendingFirstIndexStringSort)
|
||||
.map(([name, value]) => ({ name: originalCaseMap.get(name)!, value }));
|
||||
}
|
||||
|
||||
// Determine whether the default User-Agent should be suppressed (explicitly disabled, or when all custom User-Agent headers are disabled).
|
||||
export function shouldSuppressUserAgent({
|
||||
request,
|
||||
requestGroups = [],
|
||||
}: {
|
||||
request: Pick<Request | WebSocketRequest | SocketIORequest, 'headers' | 'disableUserAgentHeader'>;
|
||||
requestGroups?: Pick<RequestGroup, 'headers'>[];
|
||||
}): boolean {
|
||||
const userAgentHeaders = [...requestGroups, request]
|
||||
.flatMap(doc => doc.headers || [])
|
||||
.filter(h => h.name?.toLowerCase() === 'user-agent');
|
||||
return (
|
||||
Boolean(request.disableUserAgentHeader) ||
|
||||
(userAgentHeaders.length > 0 && userAgentHeaders.every(h => h.disabled))
|
||||
);
|
||||
}
|
||||
|
||||
// (only used for getOAuth2 token) Intended to gather all required database objects and initialize ids
|
||||
export const fetchRequestGroupData = async (requestGroupId: string) => {
|
||||
const requestGroup = await services.requestGroup.getById(requestGroupId);
|
||||
@@ -544,9 +562,9 @@ const tryToExecuteScript = async (context: RequestAndContextAndOptionalResponse)
|
||||
},
|
||||
iterationData: userUploadEnvironment
|
||||
? {
|
||||
name: userUploadEnvironment.name,
|
||||
data: userUploadEnvironment.data || {},
|
||||
}
|
||||
name: userUploadEnvironment.name,
|
||||
data: userUploadEnvironment.data || {},
|
||||
}
|
||||
: undefined,
|
||||
execution: {
|
||||
...execution, // keep some existing properties in the after-response script from the pre-request script
|
||||
|
||||
@@ -137,6 +137,21 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
|
||||
throw new Error(`[plugin-window] Action not found: ${pluginName}/${label}`);
|
||||
}
|
||||
|
||||
// A1: with the sandbox on, a user plugin's action (a throw-stub here after discovery) runs in the
|
||||
// main-process sandbox over the templating-worker-database protocol; bundle plugins and the
|
||||
// flag-off path run in-process.
|
||||
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
|
||||
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
|
||||
if (sandboxEnabled && entry.plugin.directory !== '') {
|
||||
await fetchFromTemplateWorkerDatabase('plugin.runUserAction', {
|
||||
plugin: { directory: entry.plugin.directory, name: entry.plugin.name, permissions: entry.plugin.permissions },
|
||||
actionKind: type,
|
||||
actionLabel: label,
|
||||
actionDomainData: domainData,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = {
|
||||
...pluginApp.init(),
|
||||
...pluginData.init(projectId),
|
||||
|
||||
@@ -267,6 +267,8 @@ exports[`sandbox surface > matches surface snapshot 1`] = `
|
||||
"globalThis.__describeExports.prototype: object",
|
||||
"globalThis.__invoke: function(0)",
|
||||
"globalThis.__invoke.prototype: object",
|
||||
"globalThis.__invokeAction: function(0)",
|
||||
"globalThis.__invokeAction.prototype: object",
|
||||
"globalThis.__invokeHook: function(0)",
|
||||
"globalThis.__invokeHook.prototype: object",
|
||||
"globalThis.__loadPluginEntry: function(0)",
|
||||
|
||||
@@ -537,6 +537,23 @@ export const IN_SANDBOX_BOOTSTRAP = [
|
||||
' });',
|
||||
' };',
|
||||
|
||||
// --- run a plugin action (A1) ---
|
||||
// Actions are fire-and-effect: action(context, domainData) returns void and performs side effects
|
||||
// only through the capability-gated context.* bridge (built by __buildContext). The domain models
|
||||
// are copied in as plain data; nothing is marshaled back. The action is found by its label within
|
||||
// the kind-specific list (request/requestGroup/workspace/document Actions).
|
||||
' globalThis.__invokeAction = function () {',
|
||||
' var env = __env;',
|
||||
' var ctx = globalThis.__buildContext(env);',
|
||||
' var mod = globalThis.__loadPluginEntry() || {};',
|
||||
' var lists = { request: mod.requestActions, requestGroup: mod.requestGroupActions, workspace: mod.workspaceActions, document: mod.documentActions };',
|
||||
' var actions = lists[env.actionKind] || [];',
|
||||
' var entry = null;',
|
||||
' for (var i = 0; i < actions.length; i++) { if (actions[i] && actions[i].label === env.actionLabel) { entry = actions[i]; break; } }',
|
||||
' if (!entry || typeof entry.action !== "function") { throw new Error("Plugin " + env.actionKind + " action not found: " + env.actionLabel); }',
|
||||
' return Promise.resolve(entry.action(ctx, env.actionDomainData)).then(function () { return "{}"; });',
|
||||
' };',
|
||||
|
||||
// --- describe the plugin's exports without running any of them (L1 load-time discovery) ---
|
||||
// Evaluates the plugin entry (its top-level code runs here, in the sandbox — never on the host)
|
||||
// and returns a JSON-serializable manifest of what it exports: template-tag metadata, hook counts,
|
||||
@@ -573,7 +590,7 @@ export const IN_SANDBOX_BOOTSTRAP = [
|
||||
// context/invocation entry points. __registerModule is intentionally left mutable — the module
|
||||
// registry source deletes it once the registry is populated.
|
||||
' var __lock = function (n) { Object.defineProperty(globalThis, n, { value: globalThis[n], writable: false, configurable: false }); };',
|
||||
' __lock("__require"); __lock("__buildContext"); __lock("__invoke"); __lock("__loadPluginEntry"); __lock("__describeExports"); __lock("__invokeHook");',
|
||||
' __lock("__require"); __lock("__buildContext"); __lock("__invoke"); __lock("__loadPluginEntry"); __lock("__describeExports"); __lock("__invokeHook"); __lock("__invokeAction");',
|
||||
'})();',
|
||||
].join('\n');
|
||||
|
||||
@@ -591,3 +608,9 @@ export const DESCRIBE_RUNNER = 'globalThis.__task = Promise.resolve(globalThis._
|
||||
* `globalThis.__task`. Used instead of {@link RUNNER} to run a request/response hook.
|
||||
*/
|
||||
export const HOOK_RUNNER = 'globalThis.__task = globalThis.__invokeHook();';
|
||||
|
||||
/**
|
||||
* Action runner (A1): parks a resolved promise on `globalThis.__task` after running the plugin
|
||||
* action. Actions are fire-and-effect, so the resolved value is an empty JSON object.
|
||||
*/
|
||||
export const ACTION_RUNNER = 'globalThis.__task = globalThis.__invokeAction();';
|
||||
|
||||
@@ -57,6 +57,18 @@ export interface ContextEnvelope {
|
||||
hookRequest?: Record<string, unknown>;
|
||||
/** The response patch a response hook may read (getters only; `setBody` bridges to the host). */
|
||||
hookResponse?: Record<string, unknown>;
|
||||
/**
|
||||
* Plugin action invocation (A1). When set, the sandbox runs the plugin's action matching
|
||||
* `actionLabel` in the `actionKind` list (requestActions/requestGroupActions/…) instead of a tag
|
||||
* or hook. The action reads the copied-in `actionDomainData` (plain models) and performs side
|
||||
* effects only through the capability-gated `context.*` bridge; it returns void, so nothing is
|
||||
* marshaled back.
|
||||
*/
|
||||
actionKind?: 'request' | 'requestGroup' | 'workspace' | 'document';
|
||||
/** The action's `label` (unique per plugin per kind) — how the sandbox finds it in the list. */
|
||||
actionLabel?: string;
|
||||
/** The serializable domain models passed as the action's second argument. */
|
||||
actionDomainData?: unknown;
|
||||
}
|
||||
|
||||
/** What a hook invocation marshals back out of the sandbox: the mutated request/response data. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten';
|
||||
|
||||
import type { HostBridge } from './host-bridge';
|
||||
import { DESCRIBE_RUNNER, HOOK_RUNNER, IN_SANDBOX_BOOTSTRAP, RUNNER } from './in-sandbox-bootstrap';
|
||||
import { ACTION_RUNNER, DESCRIBE_RUNNER, HOOK_RUNNER, IN_SANDBOX_BOOTSTRAP, RUNNER } from './in-sandbox-bootstrap';
|
||||
import { type ContextEnvelope, encodeBridgeFailure, encodeBridgeSuccess } from './marshal';
|
||||
import { buildModuleRegistrySource } from './module-registry';
|
||||
import { SANDBOX_GLOBALS_SOURCE } from './sandbox-globals';
|
||||
@@ -97,7 +97,13 @@ export const runTagInSandbox = async (opts: RunTagInSandboxOptions): Promise<str
|
||||
evalOrThrow(ctx, buildModuleRegistrySource(fullEnvelope.grantedModules), '<sandbox-modules>');
|
||||
// Plugin source travels as envelope DATA (moduleFiles) and is compiled by the in-sandbox loader
|
||||
// when __invoke()/__describeExports()/__invokeHook() loads the entry — no host-side eval.
|
||||
const runner = envelope.hookKind ? HOOK_RUNNER : discover ? DESCRIBE_RUNNER : RUNNER;
|
||||
const runner = envelope.actionKind
|
||||
? ACTION_RUNNER
|
||||
: envelope.hookKind
|
||||
? HOOK_RUNNER
|
||||
: discover
|
||||
? DESCRIBE_RUNNER
|
||||
: RUNNER;
|
||||
evalOrThrow(ctx, runner, '<runner>');
|
||||
|
||||
return await drivePromiseToString(ctx, timeoutMs);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type HostBridge } from './host-bridge';
|
||||
import { type ContextEnvelope } from './marshal';
|
||||
import { runTagInSandbox } from './plugin-tag-sandbox';
|
||||
|
||||
// A1: plugin actions run inside the sandbox. An action is fire-and-effect — it reads the copied-in
|
||||
// domain models and performs side effects only through the capability-gated context.* bridge; nothing
|
||||
// is marshaled back. These tests drive the action path directly with a hand-built envelope.
|
||||
|
||||
const runAction = async (
|
||||
actionSource: string,
|
||||
opts: {
|
||||
kind: NonNullable<ContextEnvelope['actionKind']>;
|
||||
label: string;
|
||||
domainData?: unknown;
|
||||
grantedCapabilities?: string[];
|
||||
},
|
||||
): Promise<{ path: string; body: any }[]> => {
|
||||
const calls: { path: string; body: any }[] = [];
|
||||
const bridge: HostBridge = async (path, body) => {
|
||||
calls.push({ path, body });
|
||||
if (path === 'pluginData.setItem') {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`unexpected bridge call: ${path}`);
|
||||
};
|
||||
const envelope: ContextEnvelope = {
|
||||
args: [],
|
||||
context: {},
|
||||
meta: {},
|
||||
renderPurpose: 'send',
|
||||
appInfo: { version: '0.0.0', platform: 'linux', arch: 'x64' },
|
||||
pluginName: 'test-plugin',
|
||||
renderDepth: 0,
|
||||
grantedModules: ['path', 'crypto'],
|
||||
grantedCapabilities: opts.grantedCapabilities ?? [],
|
||||
moduleFiles: { 'index.js': `module.exports.${opts.kind}Actions = [${actionSource}];` },
|
||||
entryModuleKey: 'index.js',
|
||||
actionKind: opts.kind,
|
||||
actionLabel: opts.label,
|
||||
actionDomainData: opts.domainData,
|
||||
};
|
||||
await runTagInSandbox({ tagName: '', envelope, bridge });
|
||||
return calls;
|
||||
};
|
||||
|
||||
describe('plugin actions in the sandbox (A1)', () => {
|
||||
it('runs the action matching the label, passing domain models, with side effects via a granted capability', async () => {
|
||||
const calls = await runAction(
|
||||
`{ label: 'Wrong', action: function () { throw new Error('should not run'); } },
|
||||
{ label: 'Store It', action: async function (context, models) {
|
||||
await context.store.setItem('acted-on', models.request._id);
|
||||
} }`,
|
||||
{ kind: 'request', label: 'Store It', domainData: { request: { _id: 'req_42' } }, grantedCapabilities: ['storage'] },
|
||||
);
|
||||
const setItem = calls.find(c => c.path === 'pluginData.setItem');
|
||||
expect(setItem).toBeTruthy();
|
||||
expect(setItem!.body).toMatchObject({ key: 'acted-on', value: 'req_42' });
|
||||
});
|
||||
|
||||
it('denies an ungranted capability (context.network absent when not granted)', async () => {
|
||||
await expect(
|
||||
runAction(`{ label: 'Net', action: async function (context) { await context.network.sendRequest({}); } }`, {
|
||||
kind: 'request',
|
||||
label: 'Net',
|
||||
grantedCapabilities: [],
|
||||
}),
|
||||
).rejects.toThrow(/sendRequest/i);
|
||||
});
|
||||
|
||||
it('throws a clear error when no action matches the label', async () => {
|
||||
await expect(
|
||||
runAction(`{ label: 'Exists', action: function () {} }`, { kind: 'request', label: 'Missing' }),
|
||||
).rejects.toThrow(/action not found: Missing/i);
|
||||
});
|
||||
|
||||
it('resolves the kind-specific list (documentActions) independently', async () => {
|
||||
const calls = await runAction(
|
||||
`{ label: 'Doc', action: async function (context, spec) { await context.store.setItem('doc', spec.name); } }`,
|
||||
{ kind: 'document', label: 'Doc', domainData: { name: 'my-spec' }, grantedCapabilities: ['storage'] },
|
||||
);
|
||||
expect(calls.find(c => c.path === 'pluginData.setItem')!.body).toMatchObject({ key: 'doc', value: 'my-spec' });
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,7 @@ export const SANDBOX_INTERNAL_GLOBALS = [
|
||||
'__buildContext',
|
||||
'__describeExports',
|
||||
'__invoke',
|
||||
'__invokeAction',
|
||||
'__invokeHook',
|
||||
'__loadPluginEntry',
|
||||
'__require',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CookieJar, Request, RequestAuthentication, RequestGroup, RequestHe
|
||||
import { models, services } from 'insomnia-data';
|
||||
|
||||
import { database as db } from '../../common/database';
|
||||
import { getOrInheritAuthentication, getOrInheritHeaders } from '../../network/network';
|
||||
import { getOrInheritAuthentication, getOrInheritHeaders, shouldSuppressUserAgent } from '../../network/network';
|
||||
import { tryToInterpolateRequestOrShowRenderErrorModal } from './try-interpolate';
|
||||
|
||||
const { applyPathParametersToUrl } = models.request;
|
||||
@@ -52,12 +52,7 @@ export async function renderRealtimeConnectPayload({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// getOrInheritHeaders drops disabled headers, so disabled User-Agent headers are only visible on the raw request/folder headers
|
||||
const userAgentHeaders = [...requestGroups, request]
|
||||
.flatMap(doc => doc.headers || [])
|
||||
.filter(h => h.name?.toLowerCase() === 'user-agent');
|
||||
const suppressUserAgent =
|
||||
Boolean(request.disableUserAgentHeader) || (userAgentHeaders.length > 0 && userAgentHeaders.every(h => h.disabled));
|
||||
const suppressUserAgent = shouldSuppressUserAgent({ request, requestGroups });
|
||||
|
||||
const url = applyPathParametersToUrl(rendered.url, rendered.pathParameters);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user