efa0b0578c
- 沙箱改用 quickjs-emscripten singlefile variant(wasm 內嵌 base64、同步載入), CF Workers 相容;sandbox.mjs 成 Node/Worker 共用 runtime-agnostic 核心。 - sha256 curated builtin 改「純 JS SHA-256 prelude 字串注入」,去掉 node:crypto / async Web Crypto host-call,Node/Worker 皆決定性(card content_hash 逐字等價)。 - index.ts 成可部署 Worker host(Hono,POST /→runCode),自足不走 TinyGo 模板流程。 - 補 wrangler.toml(arcrun-code / code.arcrun.dev)、tsconfig、DEPLOY.md。 - contract stability 修為 floating(過 registry zod schema 驗證)。 - 單測 12/12 全綠(含 card→envelope 與原模組 planCard 逐欄全等)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
190 lines
7.8 KiB
JavaScript
190 lines
7.8 KiB
JavaScript
// 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「singlefile」variant(wasm 內嵌為 base64、同步載入),
|
||
// 這是 Cloudflare Workers 相容的 loading 路徑(不靠 fetch/fs 取 .wasm)。
|
||
//
|
||
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、no_filesystem_syscall。
|
||
|
||
import variant from '@jitl/quickjs-singlefile-mjs-release-sync';
|
||
import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core';
|
||
|
||
export const DEFAULT_LIMITS = {
|
||
timeout_ms: 1000, // 執行牆鐘上限(interrupt handler 逐指令檢查 deadline)
|
||
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 (!_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;
|
||
runtime.setInterruptHandler(() => {
|
||
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 timed out after ${limits.timeout_ms}ms`, '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 timed out after ${limits.timeout_ms}ms`, '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 };
|
||
}
|