diff --git a/registry/components/code/DEPLOY.md b/registry/components/code/DEPLOY.md new file mode 100644 index 0000000..d0f83cb --- /dev/null +++ b/registry/components/code/DEPLOY.md @@ -0,0 +1,48 @@ +# `code` 零件 —— 部署清單(過閘用;本輪不執行) + +> 封裝=A(quickjs-emscripten singlefile variant)。`code` 是**自足 Worker**,不走 +> `deploy-logic-components.sh`(那條是 TinyGo-wasm 專用)。以下由 leo 過閘後執行。 + +## 1. 部署 code Worker(wrangler,禁 acr update) + +```bash +cd registry/components/code +npm install # 裝 hono + quickjs-emscripten-core + singlefile variant +npm test # 12 測試須綠(部署前 gate) +npx wrangler deploy # → arcrun-code,route code.arcrun.dev/* +``` + +- Worker 名:`arcrun-code`;route:`code.arcrun.dev/*`(見 `wrangler.toml`)。 +- **無需注入任何 secret/env**:`code` 零件不碰網路/env,Worker 本身零 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` / +本輪回報的「一次過閘清單」。 diff --git a/registry/components/code/component.contract.yaml b/registry/components/code/component.contract.yaml index a46561e..119e4b4 100644 --- a/registry/components/code/component.contract.yaml +++ b/registry/components/code/component.contract.yaml @@ -3,7 +3,7 @@ display_name: "程式碼(沙箱 inline JS)" category: "logic" version: "v1" wasi_target: "preview1" -stability: "experimental" +stability: "floating" runtime_compat: - "cf-workers" - "workerd" diff --git a/registry/components/code/index.ts b/registry/components/code/index.ts index cdf3b6a..8933787 100644 --- a/registry/components/code/index.ts +++ b/registry/components/code/index.ts @@ -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` 零件 = 載入「QuickJS(JS 直譯器)編成的 wasm」,把 user 的 inline JS 當「資料」 - * 餵進去跑。QuickJS 的 wasm 由 quickjs-emscripten 提供(有 Cloudflare Workers 相容 variant)。 + * 封裝=A(quickjs-emscripten singlefile variant):wasm 內嵌為 base64、同步載入, + * bundler 友善、無需 [[wasm_modules]] 綁定、無需 nodejs_compat(sandbox 用 TextEncoder 計 bytes)。 * - * ⚠️ 此檔為設計骨架。PoC 的可執行 + 受測實作在 ./sandbox.mjs(Node/vitest 綠燈)。 - * live 化差異見 DESIGN.md「② 生產路徑」與「curated builtins(sha256)」。 + * 與其他 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.mjs(PoC 已受測)。生產版把 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 永遠回結構化 envelope;success=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, -): Promise; - -// 保留 import 以標示相依(bundler 不 tree-shake 掉): -void getQuickJS; diff --git a/registry/components/code/package-lock.json b/registry/components/code/package-lock.json index da29088..fcb896f 100644 --- a/registry/components/code/package-lock.json +++ b/registry/components/code/package-lock.json @@ -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", diff --git a/registry/components/code/package.json b/registry/components/code/package.json index fa9bee5..fd6d9da 100644 --- a/registry/components/code/package.json +++ b/registry/components/code/package.json @@ -3,14 +3,18 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "arcrun code 零件 —— sandbox inline JS(QuickJS-wasm)", + "description": "arcrun code 零件 —— sandbox inline JS(QuickJS-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" } } diff --git a/registry/components/code/sandbox.mjs b/registry/components/code/sandbox.mjs index cb7289a..c54a24e 100644 --- a/registry/components/code/sandbox.mjs +++ b/registry/components/code/sandbox.mjs @@ -1,4 +1,4 @@ -// arcrun `code` 零件 —— 沙箱核心(PoC 參考實作) +// arcrun `code` 零件 —— 沙箱核心(runtime-agnostic:Node 與 CF Workers 共用同一份) // --------------------------------------------------------------------------- // 語義:n8n Code node 式。config 帶一段 inline user JS,stdin 帶 input JSON。 // user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。 @@ -6,49 +6,123 @@ // // 隔離機制: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)。 +// 沒有 fetch / process / require / globalThis.env / WebAssembly / 任何 host binding。 +// 要給的能力,只能由 host 明確、逐一注入 —— 目前唯一 curated builtin = 純函式 `sha256`, +// 且以「純 JS 演算法字串 prelude」注入(不呼叫 host、不用 async Web Crypto,Node/Worker 皆決定性)。 // -// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、 -// no_filesystem_syscall。與其他零件唯一差別=直譯器是 QuickJS-wasm 而非 TinyGo-wasm。 +// 封裝方式: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 { 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 實作 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'); +// --- 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, 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 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。 + // 包裝: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 }; } diff --git a/registry/components/code/tsconfig.json b/registry/components/code/tsconfig.json new file mode 100644 index 0000000..8433a86 --- /dev/null +++ b/registry/components/code/tsconfig.json @@ -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"] +} diff --git a/registry/components/code/wrangler.toml b/registry/components/code/wrangler.toml new file mode 100644 index 0000000..23dd007 --- /dev/null +++ b/registry/components/code/wrangler.toml @@ -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"