This commit is contained in:
seb
2026-09-27 23:45:07 +02:00
parent 0149846a15
commit 0d42b51f63
11 changed files with 897 additions and 13 deletions

220
test/javascript.test.js Normal file
View File

@@ -0,0 +1,220 @@
'use strict';
/**
* javascript_execute — sandbox execution, isolation guarantees, and the
* timeout/serialization behaviour of the worker bridge.
*
* These tests spawn real worker threads and run real vm2 sandboxes: the
* security properties (a runaway loop, blocked host modules) are exactly
* what a mocked-out vm2 would fail to verify.
*
* Note: no test passes allow_network=true, because that path requires an
* interactive TTY approval prompt. Network plumbing is exercised separately
* in a scratch worker, not here.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import { javascriptExecuteTool } from '../src/tools/javascript.js';
const WORKER_PATH = fileURLToPath(new URL('../src/tools/javascript-worker.js', import.meta.url));
/** Run a snippet through the real tool, normalizing errors to a message. */
const exec = (args) =>
javascriptExecuteTool.invoke(args).then(
(result) => ({ ok: true, result }),
(err) => ({ ok: false, error: err.message }),
);
/**
* Drive the worker directly, bypassing the approval layer, so worker-level
* behaviour (timeouts, serialization) can be asserted without a TTY.
*/
function runInWorker(code, { network = false, timeout = 5000, cap = 20_000 } = {}) {
return new Promise((resolve) => {
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: process.cwd(),
vmTimeout: timeout,
bufferLimit: 32 * 1024 * 1024,
network,
},
});
const bail = setTimeout(() => {
worker.terminate();
resolve({ timedOut: true });
}, cap);
worker.on('message', (msg) => {
clearTimeout(bail);
worker.terminate();
resolve(msg);
});
worker.on('error', (err) => {
clearTimeout(bail);
worker.terminate();
resolve({ error: err.message });
});
});
}
// ─── Result serialization ───────────────────────────────────────────────────
test('a snippet returns its value via module.exports', async () => {
const { ok, result } = await exec({ code: 'module.exports = 6 * 7;' });
assert.ok(ok, 'expected the call to succeed');
assert.equal(result.result, 42);
});
test('nested objects and arrays survive the worker boundary', async () => {
// vm2 returns Proxy objects; structured-clone rejects them outright, so
// the worker rebuilds plain host containers before posting.
const { ok, result } = await exec({
code: 'module.exports = { a: 1, b: [1, 2, { c: true }], d: "x" };',
});
assert.ok(ok);
assert.deepEqual(result.result, { a: 1, b: [1, 2, { c: true }], d: 'x' });
});
test('a returned array is not reported as unserializable', async () => {
const { ok, result } = await exec({ code: 'module.exports = [[1, 2], [3, { d: 4 }]];' });
assert.ok(ok);
assert.deepEqual(result.result, [[1, 2], [3, { d: 4 }]]);
});
test('a returned function degrades to a type marker instead of throwing', async () => {
const { ok, result } = await exec({ code: 'module.exports = function f() {};' });
assert.ok(ok);
assert.deepEqual(result.result, { type: 'function' });
});
test('values nested deeper than the depth limit are summarized, not dropped silently', async () => {
const { ok, result } = await exec({ code: 'module.exports = { a: { b: { c: { d: { e: 1 } } } } };' });
assert.ok(ok);
assert.equal(result.result.a.b.c.d.type, 'object');
assert.match(result.result.a.b.c.d.note, /deeply nested/);
});
// ─── Console capture ────────────────────────────────────────────────────────
test('console output is captured and returned rather than printed to the terminal', async () => {
const { ok, result } = await exec({
code: 'console.log("plain"); console.error("boom"); module.exports = 1;',
});
assert.ok(ok);
assert.equal(result.stdout, 'plain\nerror: boom');
assert.equal(result.consoleLines, 2);
});
test('console output beyond max_output is capped and flagged', async () => {
const { ok, result } = await exec({
code: 'for (let i = 0; i < 100; i++) console.log("x".repeat(20));',
max_output: 60,
});
assert.ok(ok);
assert.equal(result.truncated, true);
assert.ok(result.stdout.length <= 60, 'stdout must respect the cap');
assert.match(result.truncationNote, /60 chars/);
});
// ─── Errors ─────────────────────────────────────────────────────────────────
test('a thrown error surfaces as a ToolError carrying name, message and stack', async () => {
const { ok, error } = await exec({ code: 'null.x;' });
assert.equal(ok, false);
assert.match(error, /Cannot read properties of null/);
assert.match(error, /TypeError/);
});
test('a syntax error is reported rather than crashing the worker', async () => {
const { ok, error } = await exec({ code: 'const = ;' });
assert.equal(ok, false);
assert.ok(error.length > 0);
});
test('blank code is rejected before any worker is spawned', async () => {
const { ok, error } = await exec({ code: ' ' });
assert.equal(ok, false);
assert.match(error, /non-empty string/);
});
test('a non-positive timeout is rejected', async () => {
const { ok, error } = await exec({ code: '1;', timeout: 0 });
assert.equal(ok, false);
assert.match(error, /positive integer/);
});
// ─── Timeout: the reason this runs in a worker at all ───────────────────────
test('an infinite loop is killed at the timeout instead of hanging', async () => {
// The core guarantee. NodeVM does not honor vm2's own `timeout`, so
// without the worker being terminated this call never returns.
const started = Date.now();
const { ok, result } = await exec({ code: 'while (true) {}', timeout: 1000 });
const elapsed = Date.now() - started;
assert.ok(ok, 'a timeout is a normal outcome, not a thrown error');
assert.equal(result.timedOut, true);
assert.ok(elapsed < 8000, `expected a prompt kill, took ${elapsed} ms`);
assert.match(result.note, /timeout/);
});
test('the worker-level hard kill also stops a blocking loop', async () => {
const report = await runInWorker('while (true) {}', { cap: 3000 });
assert.equal(report.timedOut, true);
});
// ─── Isolation ──────────────────────────────────────────────────────────────
test('built-in host modules are not reachable', async () => {
for (const mod of ['fs', 'child_process', 'os', 'node:fs']) {
const { ok, error } = await exec({ code: `require(${JSON.stringify(mod)});` });
assert.equal(ok, false, `${mod} must not be requireable`);
assert.match(error, /Cannot find module/);
}
});
test('node_modules packages are not reachable', async () => {
const { ok, error } = await exec({ code: 'require("chalk");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('vm2 itself is not reachable (no nesting escape)', async () => {
const { ok, error } = await exec({ code: 'require("vm2");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('fetch is absent unless network access was granted', async () => {
const offline = await runInWorker('module.exports = typeof fetch;');
assert.equal(offline.result, 'undefined');
});
// ─── Async ──────────────────────────────────────────────────────────────────
test('a promise assigned to module.exports is awaited', async () => {
const { ok, result } = await exec({
code: 'async function f() { return 41 + 1; }\nmodule.exports = f();',
});
assert.ok(ok);
assert.equal(result.result, 42);
});
test('a rejected promise is reported as an error', async () => {
const { ok, error } = await exec({
code: 'module.exports = (async () => { throw new Error("async boom"); })();',
});
assert.equal(ok, false);
assert.match(error, /async boom/);
});
test('a promise that never settles is bounded by the timeout', async () => {
const report = await runInWorker('module.exports = new Promise(() => {});', {
timeout: 800,
cap: 6000,
});
assert.match(String(report.error ?? ''), /did not settle/);
});