diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6468112b..b4201dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,14 +101,22 @@ jobs: node-version: '22.19.0' cache: 'npm' - run: npm ci - - run: npm install --no-save --save-prod --legacy-peer-deps ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4 ollama-ai-provider-v2@4 zod@4 + # isolated-vm (optional peer, installed here only) rides the same install so it cannot be + # reverted by a later one. Pinned to 6.2.0: 7.x requires Node >= 24 and this job runs 22.19.0. + - run: npm install --no-save --save-prod --legacy-peer-deps ai@7 @ai-sdk/openai@4 @ai-sdk/anthropic@4 ollama-ai-provider-v2@4 zod@4 isolated-vm@6.2.0 - run: npx tsc --noEmit - run: npm run test:types - run: npm run build env: NODE_OPTIONS: --max-old-space-size=12288 - run: npm run build --workspace=packages/create-lousho-agent + # Runs the real 'isolate' driver (src/sandbox/isolate.test.ts): Node >= 20 needs + # --no-node-snapshot to load isolated-vm; LOUSHO_ISOLATE_TESTS=1 makes that file fail + # instead of skip if the module cannot load. - run: npx vitest run + env: + NODE_OPTIONS: --no-node-snapshot + LOUSHO_ISOLATE_TESTS: '1' - run: docker pull curlimages/curl:8.10.1 && docker pull node:20-alpine && docker pull node:22-slim - run: npm run test:docker env: diff --git a/src/sandbox/isolate.test.ts b/src/sandbox/isolate.test.ts index 64ecd9c7..fdf9ade2 100644 --- a/src/sandbox/isolate.test.ts +++ b/src/sandbox/isolate.test.ts @@ -1,11 +1,13 @@ /** * The 'isolate' driver against an injected `isolated-vm` module, so the * timeout/OOM/driver mapping and the memory/timeout forwarding are tested - * without the native package. + * without the native package. The last block runs the real driver against the + * native package (compat CI only; see probeIsolatedVm below). */ +import { createRequire } from 'node:module'; import { describe, it, expect } from 'vitest'; -import { createIsolateDriver, ISOLATE_DEFAULTS } from './isolate'; -import { SandboxError } from './errors'; +import { createIsolateDriver, isolateDriver, ISOLATE_DEFAULTS } from './isolate'; +import { SandboxError, sandboxErrorKind } from './errors'; type EvalCall = { code: string; args: unknown[]; options?: { arguments?: { copy?: boolean }; result?: { copy?: boolean; promise?: boolean }; timeout?: number } }; @@ -125,6 +127,17 @@ describe('createIsolateDriver', () => { await expect(driver.run('() => 1')).rejects.toMatchObject({ kind: 'driver', driver: 'isolate' }); }); + it("maps isolated-vm's own eval timeout (isolate still alive) to a timeout SandboxError", async () => { + const { module } = fakeModule({ + eval: () => { + throw new Error('Script execution timed out.'); + }, + }); + const driver = createIsolateDriver(module, LIMITS); + + await expect(driver.run('() => 1')).rejects.toMatchObject({ kind: 'timeout', driver: 'isolate' }); + }); + it("passes the code's own error through unchanged", async () => { const { module } = fakeModule(); const driver = createIsolateDriver(module, LIMITS); @@ -138,3 +151,66 @@ describe('ISOLATE_DEFAULTS', () => { expect(ISOLATE_DEFAULTS).toEqual({ memoryMb: 128, timeoutMs: 5_000 }); }); }); + +/** + * The real driver, against the native `isolated-vm` package. Only the `compat` + * CI job installs it (`npm install --no-save isolated-vm`) and runs the suite + * with `NODE_OPTIONS=--no-node-snapshot` (isolated-vm on Node >= 20 crashes + * the process without that flag, so a loadable module alone is not enough). + * Everywhere else the block skips; under `LOUSHO_ISOLATE_TESTS=1` (compat) + * a missing module or flag fails instead, so CI cannot pass by skipping. + */ +function probeIsolatedVm(): { ok: boolean; reason: string } { + const needsFlag = Number(process.versions.node.split('.')[0]) >= 20; + const hasFlag = `${process.env.NODE_OPTIONS ?? ''} ${process.execArgv.join(' ')}`.includes('--no-node-snapshot'); + if (needsFlag && !hasFlag) return { ok: false, reason: 'Node >= 20 needs --no-node-snapshot (NODE_OPTIONS) to load isolated-vm' }; + try { + createRequire(import.meta.url)('isolated-vm'); + return { ok: true, reason: '' }; + } catch (error) { + return { ok: false, reason: `isolated-vm is not loadable: ${error instanceof Error ? error.message : String(error)}` }; + } +} + +const probe = probeIsolatedVm(); +/** True when the native isolated-vm loads in this process (so the real-driver block runs). */ +const hasIsolatedVm = probe.ok; +const isolateRequired = process.env.LOUSHO_ISOLATE_TESTS === '1'; + +if (isolateRequired && !hasIsolatedVm) { + describe('isolate driver (real isolated-vm)', () => { + it('is available when LOUSHO_ISOLATE_TESTS=1', () => { + throw new Error(`LOUSHO_ISOLATE_TESTS=1 is set but the real isolate driver cannot run: ${probe.reason}`); + }); + }); +} + +describe.runIf(hasIsolatedVm)('isolate driver (real isolated-vm)', () => { + it('runs a real tool body and returns its value', async () => { + const driver = isolateDriver({ memoryMb: 128, timeoutMs: 5_000 }); + await expect(driver.run('(a, b) => a + b', { args: [2, 40] })).resolves.toBe(42); + await expect(driver.run('async (n) => { await null; return { doubled: n * 2 }; }', { args: [21] })).resolves.toEqual({ doubled: 42 }); + }); + + it("passes the code's own error through unchanged", async () => { + const driver = isolateDriver({ memoryMb: 128, timeoutMs: 5_000 }); + const error = await driver.run('() => { throw new RangeError("boom") }').catch((e: unknown) => e); + expect(sandboxErrorKind(error)).toBeUndefined(); + expect((error as Error).message).toContain('boom'); + }); + + it('reports a timeout for a synchronous infinite loop', async () => { + const driver = isolateDriver({ memoryMb: 128, timeoutMs: 100 }); + const error = await driver.run('() => { while (true) {} }').catch((e: unknown) => e); + expect(error).toBeInstanceOf(SandboxError); + expect(sandboxErrorKind(error)).toBe('timeout'); + }, 15_000); + + it('reports oom for a 256 MB allocation under a 32 MB cap', async () => { + const driver = isolateDriver({ memoryMb: 32, timeoutMs: 10_000 }); + const error = await driver + .run('() => { const keep = []; for (let i = 0; i < 256; i++) keep.push(new Array(1024 * 1024 / 8).fill(i)); return keep.length; }') + .catch((e: unknown) => e); + expect(sandboxErrorKind(error)).toBe('oom'); + }, 30_000); +}); diff --git a/src/sandbox/isolate.ts b/src/sandbox/isolate.ts index 73e3d1da..beefa8d6 100644 --- a/src/sandbox/isolate.ts +++ b/src/sandbox/isolate.ts @@ -76,6 +76,28 @@ if (typeof fn !== 'function') { return fn.apply(undefined, $1); `; +/** + * What a failed run becomes: our own timer's kill or isolated-vm's eval timeout + * is 'timeout'; an isolate the engine disposed is 'oom' (the memory cap is the + * only configured limit, so a dead isolate means an OOM or a crash we report + * the same way); anything else is the code's own error, crossing unchanged. + */ +function classifyFailure(error: unknown, timedOut: boolean, disposed: boolean, limits: IsolateLimits): Error { + const engineTimeout = error instanceof Error && /Script execution timed out/i.test(error.message); + if (timedOut || engineTimeout) { + return new SandboxError(`the sandboxed run exceeded timeoutMs (${limits.timeoutMs} ms)`, 'timeout', DRIVER, { cause: error }); + } + if (disposed) { + return new SandboxError( + `the sandboxed run exceeded the isolate's memory limit (memoryMb ${limits.memoryMb}) or crashed it`, + 'oom', + DRIVER, + { cause: error } + ); + } + return error instanceof Error ? error : new Error(typeof error === 'string' ? error : String(error)); +} + async function runIsolated(module: IvmModule, limits: IsolateLimits, code: string, opts?: SandboxCallOptions): Promise { let isolate: ivm.Isolate; try { @@ -113,22 +135,7 @@ async function runIsolated(module: IvmModule, limits: IsolateLimits, code: strin stopped, ]); } catch (error) { - // Our timer disposed the isolate: the run outlasted timeoutMs. - if (timedOut) { - throw new SandboxError(`the sandboxed run exceeded timeoutMs (${limits.timeoutMs} ms)`, 'timeout', DRIVER, { cause: error }); - } - // The engine disposed it otherwise: the memory cap is the only configured - // limit, so a dead isolate means an OOM (or a crash we report the same way). - if (isolate.isDisposed) { - throw new SandboxError( - `the sandboxed run exceeded the isolate's memory limit (memoryMb ${limits.memoryMb}) or crashed it`, - 'oom', - DRIVER, - { cause: error } - ); - } - // The code's own error crosses unchanged (it is not a boundary failure). - throw error instanceof Error ? error : new Error(typeof error === 'string' ? error : String(error)); + throw classifyFailure(error, timedOut, isolate.isDisposed, limits); } finally { clearTimeout(timer); if (!isolate.isDisposed) {