feat(code): 新增通用 code 零件(sandbox inline JS)—— Arcrun#10 設計+PoC

n8n Code node 式逃生口:config 帶 inline JS、stdin 帶 input JSON、
stdout 回 {success,data}|{success:false,error,error_type}。

沙箱=QuickJS-wasm:user JS 跑在 QuickJS context,global 只有純 ECMAScript
內建 + 唯一 curated builtin sha256(純函式);碰不到網路/檔案/env/secret/
Worker 物件圖。資源上限:timeout(interrupt)/memory/stack/output/code size。

本輪=設計+PoC,未部署 leo21c。sandbox.mjs + test/ 為 Node/vitest 可跑實作
(12 測試全綠,含 card→envelope 與原模組 planCard 逐欄全等)。index.ts 為
Worker host 骨架、DESIGN.md 記錄機制/安全性質/生產路徑/設計岔路(A/B)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
Leo
2026-07-06 04:27:15 +00:00
parent ed2e42e007
commit 60f5f10ba5
11 changed files with 2154 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
// arcrun `code` 零件 —— 沙箱核心(PoC 參考實作)
// ---------------------------------------------------------------------------
// 語義: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
// —— 「無 ambient 能力(no ambient capability by construction)」。要給的能力,
// 只能由 host 明確、逐一注入為 curated builtin(本檔 = 一個純函式 sha256)。
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、
// no_filesystem_syscall。與其他零件唯一差別=直譯器是 QuickJS-wasm 而非 TinyGo-wasm。
import { getQuickJS } from 'quickjs-emscripten';
import { createHash } from 'node:crypto';
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:純、決定性、零 ambient 能力(不能碰網路/檔案/secret)。
// PoC 用 Node crypto 實作 sha256liveWorker)要換成 Web Crypto 的
// crypto.subtle.digest('SHA-256', …)(見 DESIGN.md「curated builtins」)。
function hostSha256(s) {
return createHash('sha256').update(s, 'utf8').digest('hex');
}
/**
* 在沙箱內跑一段 user code。
* @param {string} code user 的 inline JS(函式體:可含宣告、以 `return` 回值)
* @param {*} input 已解析的 stdin JSON(會以 `input` 綁進沙箱)
* @param {object} [opts] { limits, builtins }
* @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 (Buffer.byteLength(code, 'utf8') > limits.max_code_bytes) {
return err(`code exceeds max_code_bytes (${limits.max_code_bytes})`, 'ResourceError');
}
const QuickJS = await getQuickJS();
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 {
// 注入 curated builtinsha256(單一純函式;其餘一律不給)
const shaFn = ctx.newFunction('sha256', (argHandle) => {
const s = ctx.getString(argHandle);
return ctx.newString(hostSha256(s));
});
ctx.setProp(ctx.global, 'sha256', shaFn);
shaFn.dispose();
// 綁 input:以 JSON 字串安全穿越邊界,沙箱內 JSON.parse(不共享物件圖)
const inputJson = JSON.stringify(input === undefined ? null : input);
// 包裝:user code 當成函式體跑,回值 JSON.stringify 後交回 host。
// `input` 為唯一綁定;`sha256` 為唯一注入 builtin。
const wrapped = `(() => {
"use strict";
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 (Buffer.byteLength(outJson, 'utf8') > 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 err(message, error_type) {
return { success: false, error: message, error_type };
}