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:
@@ -0,0 +1,119 @@
|
||||
// arcrun `code` 零件 —— 沙箱核心(PoC 參考實作)
|
||||
// ---------------------------------------------------------------------------
|
||||
// 語義: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
|
||||
// —— 「無 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 實作 sha256;live(Worker)要換成 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 builtin:sha256(單一純函式;其餘一律不給)
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user