u
This commit is contained in:
@@ -15,6 +15,11 @@ const MAX_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_MAX_OUTPUT = 20_000;
|
||||
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
|
||||
|
||||
// Keys already logged as benign non-sandbox worker chatter. Under `node --watch` the loader
|
||||
// emits `watch:import` / `watch:require` notices on the worker's parentPort for EVERY import,
|
||||
// so warning per message floods the log. We warn once per distinct key instead.
|
||||
const WATCH_NOISE_WARNED = new Set();
|
||||
|
||||
function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
@@ -42,14 +47,57 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId }) {
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish({ timedOut: true, timeoutMs });
|
||||
console.error(`[javascript_execute] sandbox worker timed out after ${timeoutMs} ms`);
|
||||
finish({ result: null, stdout: '', consoleLineCount: 0, timedOut: true, timeoutMs });
|
||||
}, timeoutMs);
|
||||
|
||||
worker.on('message', (msg) => finish(msg));
|
||||
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
|
||||
worker.on('message', (msg) => {
|
||||
// Ignore anything that is not a genuine sandbox report. Running the server under
|
||||
// `node --watch` (the `dev` script) installs a module-loader hook that posts
|
||||
// `watch:import` notices on this same channel; treating the first message as the
|
||||
// result made the sandbox appear to run for 0ms and return nothing.
|
||||
if (!msg || msg.__sandboxReport !== true) {
|
||||
// Those loader notices fire many times per run, so warn ONCE per key rather
|
||||
// than on every message — expected, benign noise while running under --watch.
|
||||
for (const key of Object.keys(msg || {})) {
|
||||
if (!WATCH_NOISE_WARNED.has(key)) {
|
||||
WATCH_NOISE_WARNED.add(key);
|
||||
console.warn(
|
||||
`[javascript_execute] ignoring non-sandbox worker message "${key}" ` +
|
||||
'(expected under `node --watch`; logged once per key).'
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
finish(msg);
|
||||
});
|
||||
// A worker 'error' fires BEFORE any message when the thread fails to boot (e.g. an
|
||||
// import throws at startup). ALWAYS send a truthy `error` plus the full stack so the
|
||||
// parent can throw with something actionable; an empty `err.message` must never
|
||||
// degrade into a silent, result-less success payload.
|
||||
worker.on('error', (err) => finish({
|
||||
result: null,
|
||||
stdout: '',
|
||||
consoleLineCount: 0,
|
||||
error: (err && err.message)
|
||||
? err.message
|
||||
: `Sandbox worker failed to start: ${err?.stack || err?.name || 'unknown error'}`,
|
||||
errorName: (err && err.name) || 'Error',
|
||||
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
|
||||
}));
|
||||
worker.on('exit', (code) => {
|
||||
// `finish` is idempotent via `settled`, so a message that already resolved the
|
||||
// promise is never overwritten. Reaching here means the worker died WITHOUT
|
||||
// reporting — that is a hard failure, so log it and always carry a truthy error.
|
||||
if (!settled) {
|
||||
console.error(`[javascript_execute] sandbox worker exited without reporting (code ${code})`);
|
||||
}
|
||||
finish({
|
||||
error: `Sandbox worker exited unexpectedly (code ${code}).`,
|
||||
result: null,
|
||||
stdout: '',
|
||||
consoleLineCount: 0,
|
||||
error: `Sandbox worker exited unexpectedly without reporting a result (code ${code}).`,
|
||||
errorName: 'WorkerExit',
|
||||
});
|
||||
});
|
||||
@@ -82,8 +130,18 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
|
||||
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
|
||||
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
|
||||
'IMAGES WRITTEN TO THE VFS ARE DISPLAYED INLINE IN THE CHAT. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), ' +
|
||||
'reference the exact path you wrote with standard markdown `` and it renders as an image inline. ' +
|
||||
'Note the `image/` folder is reserved for database images and is read-only; write generated images anywhere else. ' +
|
||||
'Always embed images you generate this way so the user sees them. Other file types do not render inline. ' +
|
||||
'The `sharp` image library is a pre-injected GLOBAL. ' +
|
||||
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor.',
|
||||
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor. ' +
|
||||
'CRYPTO: only the WebCrypto global `crypto` is available. Node\'s `crypto` module is NOT installed, so ' +
|
||||
'`require("crypto")` will fail. Available: `crypto.randomUUID()`, `crypto.getRandomValues(typedArray)`, ' +
|
||||
'and the async `crypto.subtle` API (digest/hash, sign/verify, encrypt/decrypt, importKey/exportKey, deriveBits). ' +
|
||||
'`crypto.subtle` is async, so use it inside an async IIFE: ' +
|
||||
'`module.exports = (async () => (await crypto.subtle.digest("SHA-256", data)).byteLength)();`. ' +
|
||||
'Typed arrays work correctly across the sandbox boundary (verified against Node\'s real digests).',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
@@ -96,7 +154,12 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
|
||||
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
|
||||
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
|
||||
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`.',
|
||||
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`. ' +
|
||||
'Bare top-level `await` is a syntax error here — always wrap it in that async IIFE. ' +
|
||||
'Globals available: `fetch`, `console`, `fs`/`vfs`, `sharp`, `performance`, `URL`, `URLSearchParams`, `Headers`, ' +
|
||||
'`Request`, `Response`, `FormData`, `Blob`, `File`, `TextEncoder`, `TextDecoder`, `AbortController`, ' +
|
||||
'`AbortSignal`, `setTimeout`, `clearTimeout`, and WebCrypto `crypto` (`randomUUID`, `getRandomValues`, `subtle`). ' +
|
||||
'No Node built-ins are installable — `require("crypto")`, `require("fs")`, `require("path")` etc. all fail.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
@@ -124,18 +187,34 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
}
|
||||
|
||||
if (report.timedOut) {
|
||||
return {
|
||||
code,
|
||||
timedOut: true,
|
||||
timeoutMs: report.timeoutMs,
|
||||
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`,
|
||||
};
|
||||
throw new ToolError(
|
||||
`Sandbox exceeded the ${report.timeoutMs} ms timeout and was terminated.`
|
||||
);
|
||||
}
|
||||
|
||||
if (report.error) {
|
||||
throw new ToolError(formatSandboxError(code, report));
|
||||
}
|
||||
|
||||
// The worker ALWAYS reports a `result` key (javascript-worker.js). Its absence
|
||||
// means the worker died before reporting — that is a hard failure, not a result.
|
||||
// Throwing (rather than returning a success-shaped object) is deliberate: a
|
||||
// fabricated `{ ok:false, result:null }` reads to the model as "ran, returned
|
||||
// nothing", which is indistinguishable from a snippet that genuinely returned
|
||||
// undefined. Throw so the failure is unambiguous and gets logged.
|
||||
if (report === null || typeof report !== 'object' || !('result' in report)) {
|
||||
const keys = report && typeof report === 'object' ? Object.keys(report) : [];
|
||||
console.error(
|
||||
'[javascript_execute] sandbox worker produced no result payload',
|
||||
{ keys, report }
|
||||
);
|
||||
throw new ToolError(
|
||||
'Sandbox worker did not report a result payload. ' +
|
||||
`Report keys: [${keys.join(', ') || 'none'}]. This is a harness failure, ` +
|
||||
'not an empty result — check the server logs for the worker error above.'
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = report.stdout ?? '';
|
||||
let truncated = false;
|
||||
if (stdout.length > maxOutput) {
|
||||
@@ -143,14 +222,18 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const result = { code, result: report.result };
|
||||
if (stdout) result.stdout = stdout;
|
||||
const result = {
|
||||
ok: true,
|
||||
result: report.result,
|
||||
// Always present, even when empty, so callers can rely on the shape.
|
||||
stdout,
|
||||
};
|
||||
if (truncated) {
|
||||
result.truncated = true;
|
||||
result.truncationNote = `Console output longer than ${maxOutput} chars was capped.`;
|
||||
}
|
||||
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
|
||||
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
|
||||
result.consoleLines = report.consoleLineCount ?? 0;
|
||||
result.durationMs = report.durationMs ?? 0;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user