// arcrun `code` 零件 —— 沙箱核心(runtime-agnostic:Node 與 CF Workers 共用同一份) // --------------------------------------------------------------------------- // 語義:n8n Code node 式。config 帶一段 inline user JS,stdin 帶 input JSON。 // user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。 // user code 碰不到:網路 / 檔案 / env / secret / Worker 物件圖。 // // 隔離機制:user JS 跑在 QuickJS(JS 直譯器)編成的 wasm sandbox 內。QuickJS // context 的 global 只有純 ECMAScript 內建(Object/Array/JSON/Math/Date/String…), // 沒有 fetch / process / require / globalThis.env / WebAssembly / 任何 host binding。 // 要給的能力,只能由 host 明確、逐一注入 —— 目前唯一 curated builtin = 純函式 `sha256`, // 且以「純 JS 演算法字串 prelude」注入(不呼叫 host、不用 async Web Crypto,Node/Worker 皆決定性)。 // // 封裝方式:quickjs-emscripten「wasmfile」variant + 預編 WebAssembly.Module 注入(見下方 setVariant)。 // CF Workers 禁 runtime 從 bytes 編譯 wasm,故不用 singlefile(base64);改由 build 時編好 Module。 // // 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、no_filesystem_syscall。 // wasm 載入以「variant 注入」制:CF Workers 禁止 runtime 從 bytes 編譯 wasm // (WebAssembly.instantiate(bytes) 被 embedder 擋),故必須用「已編好的 WebAssembly.Module」。 // - Worker(index.ts):import 的 .wasm 由 wrangler 綁成 WebAssembly.Module → newVariant 注入。 // - Node/vitest:由 .wasm bytes 建 new WebAssembly.Module(...) → 同一 newVariant 路徑注入。 // 呼叫方必須在 runCode 前 setVariant()。sandbox 本身不綁定任何 variant(不 bundle 錯的 loader)。 import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core'; let _variant = null; /** 注入 quickjs variant(已含預編 WebAssembly.Module)。Worker 與 Node 各自注入自己的。 */ export function setVariant(v) { _variant = v; _modulePromise = null; } export const DEFAULT_LIMITS = { timeout_ms: 1000, // 牆鐘上限(Node/本機保護;CF 同步執行會凍結 Date.now,故非主保護) max_ticks: 500, // ★ 指令計數上限(CF 主保護):interrupt 回呼被叫超過此數即中止。 // CF Workers 凍結同步 Date.now → 純同步無窮迴圈只能靠此計數中止。 // 校準(leo21c 實測):interrupt cadence ≈ 5000 指令/tick; // 真實 card 解析 ≈ 4 ticks;CF CPU 1102 門檻 ≈ 1000+ ticks。 // 500 ticks(≈ 2.5M 指令)= card 的 125× 餘裕、且穩在 CF 門檻下。 // 需更多算力的 user code 可提高 limits.max_ticks(但別逼近 ~1000)。 memory_bytes: 16 * 1024 * 1024, // QuickJS runtime 記憶體硬上限 max_stack_bytes: 512 * 1024, // 遞迴/深堆疊上限 max_output_bytes: 1024 * 1024, // stdout JSON 大小上限(防跑飛) max_code_bytes: 256 * 1024, // user code 本身大小上限 }; // --- curated builtin:純 JS SHA-256(UTF-8 → hex)。無 host call、無 async。 --- // 以字串 prelude 注入沙箱,成為沙箱內一般函式 `sha256(str)`。與 Node crypto sha256 等價 // (測試 ⑤ 對 card 全文比對 content_hash 逐字相同)。 const SHA256_PRELUDE = ` function sha256(ascii) { function rr(n, x) { return (x >>> n) | (x << (32 - n)); } var mathPow = Math.pow, maxWord = mathPow(2, 32), result = ''; var words = [], asciiBitLength; var utf8 = []; for (var ci = 0; ci < ascii.length; ci++) { var code = ascii.charCodeAt(ci); if (code < 0x80) utf8.push(code); else if (code < 0x800) { utf8.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); } else if (code < 0xd800 || code >= 0xe000) { utf8.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); } else { ci++; code = 0x10000 + (((code & 0x3ff) << 10) | (ascii.charCodeAt(ci) & 0x3ff)); utf8.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); } } asciiBitLength = utf8.length * 8; var hash = sha256.h = sha256.h || []; var k = sha256.k = sha256.k || []; var primeCounter = k.length; var isComposite = {}; for (var candidate = 2; primeCounter < 64; candidate++) { if (!isComposite[candidate]) { for (var i2 = 0; i2 < 313; i2 += candidate) isComposite[i2] = candidate; hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0; k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0; } } hash = hash.slice(0, 8); var bytes = utf8.slice(); bytes.push(0x80); while (bytes.length % 64 - 56) bytes.push(0x00); for (var b = 0; b < bytes.length; b++) { words[b >> 2] |= bytes[b] << ((3 - b) % 4) * 8; } words[words.length] = (asciiBitLength / maxWord) | 0; words[words.length] = asciiBitLength; for (var j = 0; j < words.length;) { var w = words.slice(j, j += 16); var oldHash = hash; hash = hash.slice(0, 8); for (var i = 0; i < 64; i++) { var w15 = w[i - 15], w2 = w[i - 2]; var a = hash[0], e = hash[4]; var temp1 = hash[7] + (rr(6, e) ^ rr(11, e) ^ rr(25, e)) + ((e & hash[5]) ^ ((~e) & hash[6])) + k[i] + (w[i] = (i < 16) ? w[i] : ( w[i - 16] + (rr(7, w15) ^ rr(18, w15) ^ (w15 >>> 3)) + w[i - 7] + (rr(17, w2) ^ rr(19, w2) ^ (w2 >>> 10)) ) | 0); var temp2 = (rr(2, a) ^ rr(13, a) ^ rr(22, a)) + ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2])); hash = [(temp1 + temp2) | 0].concat(hash); hash[4] = (hash[4] + temp1) | 0; } for (var i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0; } for (var i = 0; i < 8; i++) { for (var j = 3; j + 1; j--) { var b2 = (hash[i] >> (j * 8)) & 255; result += ((b2 < 16) ? 0 : '') + b2.toString(16); } } return result; } `; let _modulePromise = null; function getModule() { if (!_variant) throw new Error('sandbox variant not set — 呼叫 setVariant() 注入預編 WebAssembly.Module'); if (!_modulePromise) _modulePromise = newQuickJSWASMModuleFromVariant(_variant); return _modulePromise; } /** * 在沙箱內跑一段 user code。 * @param {string} code user 的 inline JS(函式體:可含宣告、以 `return` 回值) * @param {*} input 已解析的 stdin JSON(會以 `input` 綁進沙箱) * @param {object} [opts] { limits } * @returns {Promise<{success:true,data:*}|{success:false,error:string,error_type?:string}>} */ export async function runCode(code, input, opts = {}) { const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) }; if (typeof code !== 'string') return err('code must be a string', 'ContractError'); if (byteLen(code) > limits.max_code_bytes) { return err(`code exceeds max_code_bytes (${limits.max_code_bytes})`, 'ResourceError'); } const QuickJS = await getModule(); const runtime = QuickJS.newRuntime(); runtime.setMemoryLimit(limits.memory_bytes); runtime.setMaxStackSize(limits.max_stack_bytes); const deadline = Date.now() + limits.timeout_ms; let interrupted = false; let ticks = 0; runtime.setInterruptHandler(() => { // 主保護(CF-safe):指令計數。CF 凍結同步 Date.now,純同步無窮迴圈只能靠此中止。 if (++ticks > limits.max_ticks) { interrupted = true; return true; } // 次保護(Node/本機):牆鐘 deadline(CF 同步期間不會推進,故僅在有 I/O 或非 CF 生效)。 if (Date.now() > deadline) { interrupted = true; return true; } return false; }); const ctx = runtime.newContext(); try { const inputJson = JSON.stringify(input === undefined ? null : input); // 包裝:sha256 prelude + input 綁定 + user code 當函式體。回值 JSON.stringify 交回 host。 const wrapped = `(() => { "use strict"; ${SHA256_PRELUDE} const input = JSON.parse(${JSON.stringify(inputJson)}); const __run = (input) => { ${code} }; const __out = __run(input); return JSON.stringify(__out === undefined ? null : __out); })()`; const evalResult = ctx.evalCode(wrapped, 'user-code.js'); if (evalResult.error) { const detail = ctx.dump(evalResult.error); evalResult.error.dispose(); if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError'); const msg = typeof detail === 'object' && detail ? `${detail.name || 'Error'}: ${detail.message || ''}`.trim() : String(detail); return err(msg, 'UserCodeError'); } const outJson = ctx.getString(evalResult.value); evalResult.value.dispose(); if (byteLen(outJson) > limits.max_output_bytes) { return err(`output exceeds max_output_bytes (${limits.max_output_bytes})`, 'ResourceError'); } return { success: true, data: JSON.parse(outJson) }; } catch (e) { if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError'); const m = e instanceof Error ? e.message : String(e); if (/out of memory|memory/i.test(m)) return err('out of memory', 'ResourceError'); return err(m, 'SandboxError'); } finally { ctx.dispose(); runtime.dispose(); } } function byteLen(s) { if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8'); return new TextEncoder().encode(s).length; } function err(message, error_type) { return { success: false, error: message, error_type }; }