feat(code): Workers 就緒化(裁定 A)—— singlefile variant + 純 JS SHA-256 prelude

- 沙箱改用 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
This commit is contained in:
Leo
2026-07-06 04:39:28 +00:00
parent 60f5f10ba5
commit efa0b0578c
8 changed files with 226 additions and 60 deletions
+102 -32
View File
@@ -1,4 +1,4 @@
// arcrun `code` 零件 —— 沙箱核心(PoC 參考實作
// arcrun `code` 零件 —— 沙箱核心(runtime-agnosticNode 與 CF Workers 共用同一份
// ---------------------------------------------------------------------------
// 語義:n8n Code node 式。config 帶一段 inline user JSstdin 帶 input JSON。
// user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。
@@ -6,49 +6,123 @@
//
// 隔離機制: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)。
// 沒有 fetch / process / require / globalThis.env / WebAssembly / 任何 host binding
// 要給的能力,只能由 host 明確、逐一注入 —— 目前唯一 curated builtin = 純函式 `sha256`
// 且以「純 JS 演算法字串 prelude」注入(不呼叫 host、不用 async Web CryptoNode/Worker 皆決定性)。
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、
// no_filesystem_syscall。與其他零件唯一差別=直譯器是 QuickJS-wasm 而非 TinyGo-wasm。
// 封裝方式:quickjs-emscripten「singlefile」variantwasm 內嵌為 base64、同步載入),
// 這是 Cloudflare Workers 相容的 loading 路徑(不靠 fetch/fs 取 .wasm
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、no_filesystem_syscall。
import { getQuickJS } from 'quickjs-emscripten';
import { createHash } from 'node:crypto';
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
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');
// --- 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 (!_modulePromise) _modulePromise = newQuickJSWASMModuleFromVariant(variant);
return _modulePromise;
}
/**
* 在沙箱內跑一段 user code。
* @param {string} code user 的 inline JS(函式體:可含宣告、以 `return` 回值)
* @param {*} input 已解析的 stdin JSON(會以 `input` 綁進沙箱)
* @param {object} [opts] { limits, builtins }
* @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 (Buffer.byteLength(code, 'utf8') > limits.max_code_bytes) {
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 getQuickJS();
const QuickJS = await getModule();
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(limits.memory_bytes);
runtime.setMaxStackSize(limits.max_stack_bytes);
@@ -62,21 +136,12 @@ export async function runCode(code, input, opts = {}) {
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。
// 包裝: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}
};
@@ -99,7 +164,7 @@ export async function runCode(code, input, opts = {}) {
const outJson = ctx.getString(evalResult.value);
evalResult.value.dispose();
if (Buffer.byteLength(outJson, 'utf8') > limits.max_output_bytes) {
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) };
@@ -114,6 +179,11 @@ export async function runCode(code, input, opts = {}) {
}
}
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 };
}