From d57e0a2bec436003f7c3f8bec5cfd4d989687ea2 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sun, 25 Oct 2026 01:11:28 +0300 Subject: [PATCH 1/4] test(sandbox): run the isolate driver for real in compat CI Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++++ src/sandbox/isolate.test.ts | 68 +++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6468112b..68dabf41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,15 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=12288 - run: npm run build --workspace=packages/create-lousho-agent + # The real 'isolate' sandbox driver: isolated-vm is an optional peer, so it is + # installed here only (not in package.json). Node >= 20 needs --no-node-snapshot + # to load it; LOUSHO_ISOLATE_TESTS=1 makes src/sandbox/isolate.test.ts fail + # instead of skip if the module cannot load. + - run: npm install --no-save --legacy-peer-deps isolated-vm@7.0.1 - 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..1294c7f4 100644 --- a/src/sandbox/isolate.test.ts +++ b/src/sandbox/isolate.test.ts @@ -3,9 +3,10 @@ * timeout/OOM/driver mapping and the memory/timeout forwarding are tested * without the native package. */ +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 } }; @@ -138,3 +139,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); +}); From 335289db56ac0075fbe75190d786d2eb78e17a05 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sun, 25 Oct 2026 01:12:02 +0300 Subject: [PATCH 2/4] ci(compat): pin isolated-vm to 6.2.0 (7.x needs Node 24) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68dabf41..be12f364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,8 @@ jobs: # installed here only (not in package.json). Node >= 20 needs --no-node-snapshot # to load it; LOUSHO_ISOLATE_TESTS=1 makes src/sandbox/isolate.test.ts fail # instead of skip if the module cannot load. - - run: npm install --no-save --legacy-peer-deps isolated-vm@7.0.1 + # Pinned to 6.2.0: isolated-vm 7.x requires Node >= 24 and this job runs Node 22.19.0. + - run: npm install --no-save --legacy-peer-deps isolated-vm@6.2.0 - run: npx vitest run env: NODE_OPTIONS: --no-node-snapshot From 32ffeb19ea995726bc4d27073015c5c9e4b91fba Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sun, 25 Oct 2026 01:13:56 +0300 Subject: [PATCH 3/4] fix(sandbox): classify isolated-vm's own eval timeout; one compat install Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 +++++------ src/sandbox/isolate.test.ts | 14 +++++++++++++- src/sandbox/isolate.ts | 4 ++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be12f364..b4201dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,19 +101,18 @@ 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 - # The real 'isolate' sandbox driver: isolated-vm is an optional peer, so it is - # installed here only (not in package.json). Node >= 20 needs --no-node-snapshot - # to load it; LOUSHO_ISOLATE_TESTS=1 makes src/sandbox/isolate.test.ts fail + # 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. - # Pinned to 6.2.0: isolated-vm 7.x requires Node >= 24 and this job runs Node 22.19.0. - - run: npm install --no-save --legacy-peer-deps isolated-vm@6.2.0 - run: npx vitest run env: NODE_OPTIONS: --no-node-snapshot diff --git a/src/sandbox/isolate.test.ts b/src/sandbox/isolate.test.ts index 1294c7f4..fdf9ade2 100644 --- a/src/sandbox/isolate.test.ts +++ b/src/sandbox/isolate.test.ts @@ -1,7 +1,8 @@ /** * 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'; @@ -126,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); diff --git a/src/sandbox/isolate.ts b/src/sandbox/isolate.ts index 73e3d1da..2d2b2df7 100644 --- a/src/sandbox/isolate.ts +++ b/src/sandbox/isolate.ts @@ -117,6 +117,10 @@ async function runIsolated(module: IvmModule, limits: IsolateLimits, code: strin if (timedOut) { throw new SandboxError(`the sandboxed run exceeded timeoutMs (${limits.timeoutMs} ms)`, 'timeout', DRIVER, { cause: error }); } + // isolated-vm's own eval timeout beat our timer (the isolate is still alive). + if (error instanceof Error && /Script execution timed out/i.test(error.message)) { + 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) { From 389bbd8325a442e12606ce034bb64975d77a2a51 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sun, 25 Oct 2026 01:28:17 +0300 Subject: [PATCH 4/4] refactor(sandbox): extract isolate failure classification (fallow complexity) Co-Authored-By: Claude Opus 5.5 --- src/sandbox/isolate.ts | 43 ++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/sandbox/isolate.ts b/src/sandbox/isolate.ts index 2d2b2df7..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,26 +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 }); - } - // isolated-vm's own eval timeout beat our timer (the isolate is still alive). - if (error instanceof Error && /Script execution timed out/i.test(error.message)) { - 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) {