Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
82 changes: 79 additions & 3 deletions src/sandbox/isolate.test.ts
Original file line number Diff line number Diff line change
@@ -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 } };

Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
39 changes: 23 additions & 16 deletions src/sandbox/isolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
let isolate: ivm.Isolate;
try {
Expand Down Expand Up @@ -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) {
Expand Down
Loading