Files
Arcrun/registry/components/code/sandbox.mjs
Leo 08a79229a5 fix(code): Workers 實部署修正 —— wasmfile+Module 載入 + tick-budget timeout(CF 實測)
leo21c 實部署發現兩個 CF 限制並修正:
1. CF 禁 runtime 從 bytes 編譯 wasm(WebAssembly.instantiate(bytes) 被 embedder 擋)
   → singlefile(base64) 內嵌不可用。改 wasmfile variant + `import wasm from './vendor/quickjs.wasm'`
   (wrangler CompiledWasm rule 綁成預編 WebAssembly.Module),newVariant({wasmModule}) 注入。
   sandbox 改 variant 注入制(setVariant):Worker 注 wrangler Module、Node 由 bytes 建 Module,
   同一 production 路徑受測。vendor/quickjs.wasm 由 postinstall 自 node_modules 複製(gitignored)。
2. CF 凍結同步執行期 Date.now → wall-clock deadline 對純同步迴圈失效(撞 CF CPU 回 1102)。
   改指令計數 interrupt(max_ticks,CF-safe),wall-clock 留 Node 保護。校準:cadence≈5000
   指令/tick、真實 card 解析≈4 ticks、CF 門檻≈1000+ ticks → 預設 max_ticks=500。
   有 body 的迴圈(含 100M 迴圈)皆乾淨回 TimeoutError;空體 while(true){} 仍由 CF CPU guard 容納。

Worker live: arcrun-code.leo21c.workers.dev(cypher-executor 以此 workers.dev 慣例位址呼叫)。
Node 單測 12/12 綠(wasmfile variant + 注入 Module,同 production 路徑)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 05:15:50 +00:00

209 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// arcrun `code` 零件 —— 沙箱核心(runtime-agnosticNode 與 CF Workers 共用同一份)
// ---------------------------------------------------------------------------
// 語義:n8n Code node 式。config 帶一段 inline user JSstdin 帶 input JSON。
// user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。
// user code 碰不到:網路 / 檔案 / env / secret / Worker 物件圖。
//
// 隔離機制:user JS 跑在 QuickJSJS 直譯器)編成的 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 CryptoNode/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」。
// - Workerindex.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 ticksCF 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-256UTF-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 };
}