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
+48
View File
@@ -0,0 +1,48 @@
# `code` 零件 —— 部署清單(過閘用;本輪不執行)
> 封裝=Aquickjs-emscripten singlefile variant)。`code` 是**自足 Worker**,不走
> `deploy-logic-components.sh`(那條是 TinyGo-wasm 專用)。以下由 leo 過閘後執行。
## 1. 部署 code Workerwrangler,禁 acr update
```bash
cd registry/components/code
npm install # 裝 hono + quickjs-emscripten-core + singlefile variant
npm test # 12 測試須綠(部署前 gate
npx wrangler deploy # → arcrun-coderoute code.arcrun.dev/*
```
- Worker 名:`arcrun-code`route`code.arcrun.dev/*`(見 `wrangler.toml`)。
- **無需注入任何 secret/env**`code` 零件不碰網路/envWorker 本身零 binding。
- 冷啟:quickjs wasm 內嵌 base64、同步 instantiate;首個請求 instantiate 一次後 module 快取。
## 2. 註冊到 registry index
```bash
REGISTRY_URL=https://registry.arcrun.dev bash registry/scripts/register-component.sh code
```
- contract 已過 zod schema 驗證(`stability: floating``category: logic`
`io_model: stdin_stdout_json``gherkin_tests` ≥2)。
- `sandbox_limits` / `config_example` 為 yaml 內文件欄位,registry index 會 strip(不影響)。
## 3. 冒煙驗證(部署後)
```bash
# 基本
curl -s https://code.arcrun.dev/ | jq # {ok:true, component:"code"}
curl -s -X POST https://code.arcrun.dev/ -H 'content-type: application/json' \
-d '{"code":"return {sum: input.a + input.b};","input":{"a":2,"b":40}}' | jq
# → {"success":true,"data":{"sum":42}}
# 隔離
curl -s -X POST https://code.arcrun.dev/ -H 'content-type: application/json' \
-d '{"code":"return typeof fetch;","input":{}}' | jq
# → {"success":true,"data":"undefined"}
```
## 4. 依賴的下游(Arcrun#8 workflow —— 另一分支)
`code` 零件是 `km_wiki_ingest_drain` workflow 的前置。workflow 在
`feat/issue-8-mechanical-wiki-ingest` 分支,以 `component: code` 引用本零件(registry 解析),
不需本零件檔案同分支。issue-8 的部署與 notes 寫入量見該分支 `DEPLOY.md` /
本輪回報的「一次過閘清單」。
@@ -3,7 +3,7 @@ display_name: "程式碼(沙箱 inline JS"
category: "logic"
version: "v1"
wasi_target: "preview1"
stability: "experimental"
stability: "floating"
runtime_compat:
- "cf-workers"
- "workerd"
+27 -24
View File
@@ -1,24 +1,25 @@
/**
* arcrun `code` 零件 —— Worker host骨架,尚未部署驗證
* arcrun `code` 零件 —— Worker host可部署
*
* POST / → { code, input, limits? } → QuickJS-wasm 沙箱 → { success, data } | { success:false, error, error_type }
* POST / → { code, input?, limits? }
* → QuickJS-wasm 沙箱(./sandbox.mjs 的 runCode
* → { success:true, data } | { success:false, error, error_type }
*
* 與其他 logic 零件的差異:
* - 其他零件 = 一顆「arcrun 自建 TinyGo→wasm」,靜態 bundle 進 Worker[[wasm_modules]]),
* 跑在 component-worker-template 的 WASI-preview1 shim 上。
* - `code` 零件 = 載入「QuickJSJS 直譯器)編成的 wasm」,把 user 的 inline JS 當「資料」
* 餵進去跑。QuickJS 的 wasm 由 quickjs-emscripten 提供(有 Cloudflare Workers 相容 variant)。
* 封裝=Aquickjs-emscripten singlefile variant):wasm 內嵌為 base64、同步載入,
* bundler 友善、無需 [[wasm_modules]] 綁定、無需 nodejs_compatsandbox 用 TextEncoder 計 bytes)。
*
* ⚠️ 此檔為設計骨架。PoC 的可執行 + 受測實作在 ./sandbox.mjsNode/vitest 綠燈)。
* live 化差異見 DESIGN.md「② 生產路徑」與「curated builtinssha256)」
* 與其他 logic 零件不同:`code` 是自足 Worker(自帶 index.ts + sandbox.mjs + quickjs variant),
* 不走 component-worker-template 的 TinyGo-wasm bundling 流程。部署見 DEPLOY.md
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getQuickJS } from 'quickjs-emscripten';
// @ts-expect-error —— sandbox.mjs 為 runtime-agnostic JS 核心(Node 測試與 Worker 共用同一份)
import { runCode } from './sandbox.mjs';
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'code' }));
app.post('/', async (c) => {
@@ -28,20 +29,22 @@ app.post('/', async (c) => {
} catch {
return c.json({ success: false, error: 'request body must be JSON', error_type: 'ContractError' }, 400);
}
// runCode 語義與簽章同 ./sandbox.mjsPoC 已受測)。生產版把 sha256 curated builtin
// 換成「純 JS SHA-256 prelude」(無 host call、Node/Worker 皆決定性),見 DESIGN.md。
const result = await runCodeInWorker(String(body.code ?? ''), body.input, body.limits);
return c.json(result);
if (typeof body.code !== 'string') {
return c.json({ success: false, error: 'code (string) is required', error_type: 'ContractError' }, 400);
}
try {
const result = await runCode(body.code, body.input, { limits: body.limits });
// sandbox 永遠回結構化 envelopesuccess=false 仍以 200 帶 error_type 回(零件語義層錯,非 HTTP 錯)
return c.json(result);
} catch (e) {
// 理論上 runCode 自己 try/catch;這層是最後保險,Worker 絕不掛。
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e), error_type: 'SandboxError' },
500,
);
}
});
export default app;
// 生產實作(待補:把 sandbox.mjs 的 runCode 移植成 Worker 版)。
declare function runCodeInWorker(
code: string,
input: unknown,
limits?: Record<string, number>,
): Promise<unknown>;
// 保留 import 以標示相依(bundler 不 tree-shake 掉):
void getQuickJS;
+16
View File
@@ -8,6 +8,7 @@
"name": "arcrun-component-code",
"version": "0.1.0",
"dependencies": {
"@jitl/quickjs-singlefile-mjs-release-sync": "^0.32.0",
"quickjs-emscripten": "^0.31.0"
},
"devDependencies": {
@@ -462,6 +463,21 @@
"integrity": "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-singlefile-mjs-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-mjs-release-sync/-/quickjs-singlefile-mjs-release-sync-0.32.0.tgz",
"integrity": "sha512-h9g2Dri6WxKNjX6a1+sFAafOVxj1n5YIA1GAzMoSdIqK1M8cG07tz3GxKVC3Gbdutz14uQ3oc3Q2lll8otInhA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jitl/quickjs-singlefile-mjs-release-sync/node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.31.0.tgz",
+7 -3
View File
@@ -3,14 +3,18 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "arcrun code 零件 —— sandbox inline JSQuickJS-wasm",
"description": "arcrun code 零件 —— sandbox inline JSQuickJS-wasm, Workers-ready",
"main": "index.ts",
"scripts": {
"test": "vitest run --config vitest.config.mjs"
},
"dependencies": {
"quickjs-emscripten": "^0.31.0"
"hono": "^4.7.0",
"quickjs-emscripten-core": "^0.31.0",
"@jitl/quickjs-singlefile-mjs-release-sync": "^0.32.0"
},
"devDependencies": {
"vitest": "^3.1.0"
"vitest": "^3.1.0",
"wrangler": "^4.0.0"
}
}
+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 };
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"allowJs": true,
"noEmit": true
},
"include": ["index.ts", "sandbox.mjs"]
}
+11
View File
@@ -0,0 +1,11 @@
name = "arcrun-code"
main = "index.ts"
compatibility_date = "2025-02-19"
workers_dev = true
[vars]
COMPONENT_ID = "code"
[[routes]]
pattern = "code.arcrun.dev/*"
zone_name = "arcrun.dev"