This commit is contained in:
seb
2026-10-04 02:14:03 +02:00
parent 9a7f345d8b
commit e5ff4ef307
15 changed files with 1178 additions and 62 deletions

View File

@@ -3,6 +3,8 @@
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
import sharp from 'sharp';
import path from 'path';
import fsModule from 'fs';
import { validateObjects, validateObject } from 'react-canvas-label-editor';
import { createVfs } from './vfs.js';
@@ -71,8 +73,139 @@ function createSandboxFetch(apiUrl, token) {
};
}
// WebCrypto, bridged across the vm2 realm boundary.
//
// Handing the raw outer-realm `globalThis.crypto` to the sandbox does NOT work: WebCrypto
// validates its arguments with `instanceof` checks against the OUTER realm's typed arrays,
// so a `Uint8Array` constructed inside the sandbox is rejected ("The data argument must be
// an integer-type TypedArray"), and `crypto.subtle.digest(...)` silently resolves to an empty
// digest instead of throwing. `crypto.randomUUID()` happens to take no argument and works.
//
// So we wrap the object and copy arguments into outer-realm typed arrays before delegating.
// Result arrays are copied back into the sandbox realm so callers get working typed arrays.
function bridgeCrypto() {
if (!globalThis.crypto) return undefined;
const toOuter = (value) => {
if (value instanceof Uint8Array) return new Uint8Array(value);
if (value instanceof Uint16Array) return new Uint16Array(value);
if (value instanceof Uint32Array) return new Uint32Array(value);
if (value instanceof Int8Array) return new Int8Array(value);
if (value instanceof Int16Array) return new Int16Array(value);
if (value instanceof Int32Array) return new Int32Array(value);
if (value instanceof Float32Array) return new Float32Array(value);
if (value instanceof Float64Array) return new Float64Array(value);
if (value instanceof BigInt64Array) return new BigInt64Array(value);
if (value instanceof BigUint64Array) return new BigUint64Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (value instanceof ArrayBuffer) return new Uint8Array(value);
return value;
};
const toInner = (buf) => {
try {
return new Uint8Array(buf);
} catch {
return buf;
}
};
const bridged = {
randomUUID: () => globalThis.crypto.randomUUID(),
getRandomValues: (arr) => globalThis.crypto.getRandomValues(toOuter(arr)),
};
if (globalThis.crypto.subtle) {
bridged.subtle = {
digest: (algo, data) =>
globalThis.crypto.subtle.digest(algo, toOuter(data)).then(toInner),
importKey: (...args) => globalThis.crypto.subtle.importKey(...args),
exportKey: (format, key, algo) =>
globalThis.crypto.subtle.exportKey(format, key, algo).then(toInner),
generateKey: (...args) => globalThis.crypto.subtle.generateKey(...args),
sign: (...args) => globalThis.crypto.subtle.sign(...args).then(toInner),
verify: (...args) => globalThis.crypto.subtle.verify(...args),
encrypt: (...args) => globalThis.crypto.subtle.encrypt(...args).then(toInner),
decrypt: (...args) => globalThis.crypto.subtle.decrypt(...args).then(toInner),
deriveBits: (...args) => globalThis.crypto.subtle.deriveBits(...args).then(toInner),
deriveKey: (...args) => globalThis.crypto.subtle.deriveKey(...args),
};
}
return bridged;
}
const bridgedCrypto = bridgeCrypto();
// `sharp` writes files DIRECTLY via libvips, bypassing Node's `fs`. Injecting the raw library
// therefore lets a snippet write anywhere the server process can — a relative path lands in
// the server's CWD and an absolute/`../` path escapes the VFS entirely. We wrap the two file
// writing entry points (`toFile` and `toFile` on the constructor, plus `toBuffer` passthrough)
// so output paths are resolved through the VFS containment rules.
//
// `toFile` keeps sharp's real signature/return value; only the path is redirected.
function bridgeSharp(vfs) {
const resolveOut = (p) => {
if (typeof p !== 'string' && !(p && typeof p.toString === 'function')) {
throw new TypeError('sharp.toFile(): output path must be a string');
}
const diskPath = vfs.resolveWritePath(String(p));
// libvips creates missing parent directories itself, so `toFile('a/b/c.png')` would
// silently materialise the whole `a/b/` tree. Node's `fs.writeFileSync` instead fails
// with ENOENT. Enforce the fs semantics here so both write paths behave identically and
// a typo'd path reports an error instead of quietly succeeding.
const dir = path.dirname(diskPath);
if (!fsModule.existsSync(dir)) {
const err = new Error(
`ENOENT: no such file or directory, open '${String(p)}'`
);
err.code = 'ENOENT';
err.path = String(p);
err.syscall = 'open';
throw err;
}
return diskPath;
};
// Patch `toFile` on sharp's PROTOTYPE, not on individual instances.
//
// Snippets chain (`sharp(...).png().resize(...).toFile(...)`) and every chained call
// returns a NEW instance, so patching one instance only catches a direct
// `sharp(...).toFile(...)`. Patching the shared prototype catches every derived instance.
const proto = sharp.prototype;
if (proto && typeof proto.toFile === 'function' && !proto.toFile.__vfsPatched) {
const originalToFile = proto.toFile;
const patched = function toFile(file, ...rest) {
return originalToFile.call(this, resolveOut(file), ...rest);
};
patched.__vfsPatched = true;
proto.toFile = patched;
}
// Factory passthrough preserving statics + prototype.
const wrapped = function sharpFactory(...args) {
return sharp(...args);
};
// Preserve statics (sharp.cache, sharp.concurrency, sharp.simd, ...) and prototype.
Object.setPrototypeOf(wrapped, sharp);
for (const key of Object.keys(sharp)) {
try { wrapped[key] = sharp[key]; } catch { /* read-only static */ }
}
return wrapped;
}
const NETWORK_GLOBALS = {
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
fetch: createSandboxFetch(dashbackUrl, dashbackToken),
// WebCrypto globals (bridged across the vm2 realm boundary — see bridgeCrypto above).
// Node's `crypto` MODULE is deliberately not exposed.
...(bridgedCrypto ? { crypto: bridgedCrypto } : {}),
Crypto: globalThis.Crypto,
// Deterministic helpers occasionally needed alongside crypto.
performance: globalThis.performance,
Headers: globalThis.Headers,
Request: globalThis.Request,
Response: globalThis.Response,
@@ -168,12 +301,26 @@ function sanitize(value, depth = 0) {
return { type: t };
}
// Every genuine sandbox report is tagged with `__sandboxReport: true`.
//
// This is REQUIRED, not cosmetic: when the server runs under `node --watch` (see the `dev`
// script), Node installs a module-loader hook that posts `watch:import` notices over the SAME
// parentPort channel the worker reports on. Without this tag the parent cannot tell a real
// report apart from loader chatter and will resolve on the wrong message.
function report(payload) {
const message = { __sandboxReport: true, ...payload };
try {
parentPort.postMessage(payload);
parentPort.postMessage(message);
} catch {
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
parentPort.postMessage({
__sandboxReport: true,
result: null,
error: 'Sandbox produced a result that could not be serialized.',
errorName: 'SerializationError',
stdout: lines.join('\n'),
consoleLineCount: lines.length,
});
} catch { /* thread exiting */ }
}
}
@@ -188,7 +335,7 @@ try {
console: sandboxConsole,
fs: vfs,
vfs,
sharp,
sharp: bridgeSharp(vfs),
validateLabelObjects: (objects) => validateObjects(JSON.parse(JSON.stringify(objects))),
validateLabelObject: (object) => validateObject(JSON.parse(JSON.stringify(object))),
...NETWORK_GLOBALS,
@@ -217,28 +364,47 @@ try {
if (value && typeof value.then === 'function') {
const settleTimeout = Math.max(1000, vmTimeout - 500);
value = await Promise.race([
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
),
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), settleTimeout)
),
]);
}
} catch (err) {
const rawMsg = (err && err.message) || String(err);
failed = {
error: (err && err.message) || String(err),
error: rawMsg,
errorName: (err && err.name) || 'Error',
cause: err && err.cause ? ((err.cause && err.cause.message) || String(err.cause)) : undefined,
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
stack: err && typeof err.stack === 'string' ? err.stack : undefined,
};
// Snippets run in a CommonJS wrapper, so bare top-level `await` is a syntax error.
// That is the single most common mistake, so say exactly how to fix it.
if (err instanceof SyntaxError && /await is only valid|Unexpected reserved word/i.test(rawMsg)) {
failed.error =
'Top-level `await` is not allowed: snippets run in a CommonJS (non-ESM) wrapper. ' +
'Wrap async code in an async IIFE and assign the promise to module.exports:\n' +
' module.exports = (async () => { const res = await fetch("/company"); return res.json(); })();';
}
}
if (failed) {
report({ ...done(), ...failed });
console.error('[javascript-worker] snippet threw:', failed.error, '\n', failed.stack || '(no stack)');
report({ result: null, ...done(), ...failed });
} else {
// `result` MUST always be present: if the key were absent, the parent's
// JSON.stringify() drops it and the tool response degenerates to a bare
// echo of the submitted source with no sign of execution.
report({ result: sanitize(value), ...done() });
}
} catch (err) {
// Setup-level failure (bad VFS dir, thrown import, etc.) — log it here too, because the
// parent only sees a message and the worker's stderr is otherwise swallowed.
console.error('[javascript-worker] fatal setup error:', (err && err.message) || String(err), '\n', (err && err.stack) || '(no stack)');
report({
result: null,
stdout: lines.join('\n'),
consoleLineCount: lines.length,
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack: err && typeof err.stack === 'string' ? err.stack : undefined,

View File

@@ -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 `![title](artifacts/example.png)` 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;
},
});

View File

@@ -50,8 +50,8 @@ export function isReadOnlyDatabasePath(filePath) {
function assertNotReadOnlyDatabasePath(filePath) {
if (isReadOnlyDatabasePath(filePath)) {
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
);
}
}
@@ -134,15 +134,32 @@ export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
const sanitizeFsError = (err, virtualPath) => {
if (!err || typeof err.message !== 'string') return err;
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
const message = err.message
.split(vfsRoot)
.join('vfs://')
.split(path.sep)
.join('/')
.replace(/vfs:\/\/\/+/g, 'vfs:///');
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
message,
path: virtual,
});
};
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const resolved = path.resolve(vfsRoot, cleanVirtual);
const relative = path.relative(vfsRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return resolved;
};
@@ -161,32 +178,43 @@ export function createVfs(vfsRoot, options = {}) {
const vfs = {
readFileSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
}
const diskPath = safeResolve(p);
return fs.readFileSync(diskPath, opt);
}
const diskPath = safeResolve(p);
try {
return fs.readFileSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
try {
return fs.writeFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
try {
return fs.appendFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
existsSync: (p) => {
const hash = extractFullHash(p);
if (hash) {
@@ -233,11 +261,14 @@ export function createVfs(vfsRoot, options = {}) {
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.mkdirSync(diskPath, opt);
},
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
@@ -245,12 +276,12 @@ export function createVfs(vfsRoot, options = {}) {
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
@@ -262,9 +293,24 @@ export function createVfs(vfsRoot, options = {}) {
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
unlink: async (p) => vfs.unlinkSync(p),
rm: async (p, opt) => vfs.rmSync(p, opt),
rm: async (p, opt) => vfs.rmSync(p, opt),
},
/**
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
* boundary instead of writing wherever the process CWD happens to be.
*
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
return safeResolve(p);
},
};