Skip to content

Commit 673b72f

Browse files
committed
feat: V8 migration - port bridges, remove isolated-vm
1 parent 26fd44d commit 673b72f

54 files changed

Lines changed: 8348 additions & 3151 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs-internal/todo.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ Priority order is:
5757
- Reads >1MB silently truncate; should return EIO.
5858
- Files: `packages/runtime/wasmvm/src/syscall-rpc.ts`
5959

60+
- [ ] Run Ralph agent with OOM protection to prevent host machine lockup.
61+
- Sandbox tests (especially runtime-driver) can consume unbounded host memory when isolation is broken (e.g., node:vm shares host heap).
62+
- Use `systemd-run --user --scope -p MemoryMax=8G -p OOMScoreAdjust=900` to cap memory and ensure OOM killer targets the agent first.
63+
- Alternative: `ulimit -v 8388608` for virtual memory cap, or `echo 1000 > /proc/self/oom_score_adj` for OOM priority only.
64+
- Consider adding this to `scripts/ralph/ralph.sh` as a default wrapper.
65+
6066
## Priority 1: Compatibility and API Coverage
6167

6268
- [ ] Fix `v8.serialize` and `v8.deserialize` to use V8 structured serialization semantics.

packages/secure-exec-browser/src/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ function revivePermissions(serialized?: SerializedPermissions): Permissions | un
137137

138138
/**
139139
* Wrap a sync function in the bridge calling convention (`applySync`) so
140-
* bridge code can call it the same way it calls isolated-vm References.
140+
* bridge code can call it the same way it calls bridge References.
141141
*/
142142
function makeApplySync<TArgs extends unknown[], TResult>(
143143
fn: (...args: TArgs) => TResult,

packages/secure-exec-core/isolate-runtime/src/inject/require-setup.ts

Lines changed: 997 additions & 6 deletions
Large diffs are not rendered by default.

packages/secure-exec-core/isolate-runtime/src/inject/setup-dynamic-import.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ const __dynamicImportHandler = async function (
2323
const allowRequireFallback =
2424
request.endsWith(".cjs") || request.endsWith(".json");
2525

26-
// V8 path returns source code (string); old ivm path returned namespace objects.
27-
// Cast is safe — this handler is only active in the legacy ivm codepath.
28-
const source = await globalThis._dynamicImport(request, referrer);
29-
30-
if (source !== null) {
31-
return source as unknown as Record<string, unknown>;
26+
const namespace = await globalThis._dynamicImport.apply(
27+
undefined,
28+
[request, referrer],
29+
{ result: { promise: true } },
30+
);
31+
32+
if (namespace !== null) {
33+
return namespace;
3234
}
3335

3436
if (!allowRequireFallback) {

packages/secure-exec-core/src/bridge/active-handles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { exposeCustomGlobal } from "../shared/global-exposure.js";
33
/**
44
* Active Handles: Mechanism to keep the sandbox alive for async operations.
55
*
6-
* isolated-vm doesn't have an event loop, so async callbacks (like child process
6+
* The V8 isolate doesn't have an event loop, so async callbacks (like child process
77
* events) would never fire because the sandbox exits immediately after synchronous
88
* code finishes. This module tracks active handles and provides a promise that
99
* resolves when all handles complete.

packages/secure-exec-core/src/bridge/child-process.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// child_process module polyfill for isolated-vm
1+
// child_process module polyfill for the sandbox
22
// Provides Node.js child_process module emulation that bridges to host
33
//
44
// Uses the active handles mechanism to keep the sandbox alive while child
@@ -496,12 +496,13 @@ function execSync(
496496
// Default maxBuffer 1MB (Node.js convention)
497497
const maxBuffer = opts.maxBuffer ?? 1024 * 1024;
498498

499-
// Use synchronous bridge call
500-
const result = _childProcessSpawnSync(
499+
// Use synchronous bridge call - result is JSON string
500+
const jsonResult = _childProcessSpawnSync.applySyncPromise(undefined, [
501501
"bash",
502502
JSON.stringify(["-c", command]),
503503
JSON.stringify({ cwd: opts.cwd, env: opts.env as Record<string, string>, maxBuffer }),
504-
);
504+
]);
505+
const result = JSON.parse(jsonResult) as { stdout: string; stderr: string; code: number; maxBufferExceeded?: boolean };
505506

506507
if (result.maxBufferExceeded) {
507508
const err: ExecError = new Error("stdout maxBuffer length exceeded");
@@ -553,11 +554,11 @@ function spawn(
553554
const effectiveCwd = opts.cwd ?? (typeof process !== "undefined" ? process.cwd() : "/");
554555

555556
// Streaming mode - spawn immediately
556-
const sessionId = _childProcessSpawnStart(
557+
const sessionId = _childProcessSpawnStart.applySync(undefined, [
557558
command,
558559
JSON.stringify(argsArray),
559560
JSON.stringify({ cwd: effectiveCwd, env: opts.env }),
560-
);
561+
]);
561562

562563
activeChildren.set(sessionId, child);
563564

@@ -572,13 +573,13 @@ function spawn(
572573
if (typeof _childProcessStdinWrite === "undefined") return false;
573574
const bytes =
574575
typeof data === "string" ? new TextEncoder().encode(data) : (data as Uint8Array);
575-
_childProcessStdinWrite(sessionId, bytes);
576+
_childProcessStdinWrite.applySync(undefined, [sessionId, bytes]);
576577
return true;
577578
};
578579

579580
child.stdin.end = (): void => {
580581
if (typeof _childProcessStdinClose !== "undefined") {
581-
_childProcessStdinClose(sessionId);
582+
_childProcessStdinClose.applySync(undefined, [sessionId]);
582583
}
583584
child.stdin.writable = false;
584585
};
@@ -592,7 +593,7 @@ function spawn(
592593
: signal === "SIGINT" || signal === 2
593594
? 2
594595
: 15;
595-
_childProcessKill(sessionId, sig);
596+
_childProcessKill.applySync(undefined, [sessionId, sig]);
596597
child.killed = true;
597598
child.signalCode = (
598599
typeof signal === "string" ? signal : "SIGTERM"
@@ -663,12 +664,13 @@ function spawnSync(
663664
// Pass maxBuffer through to host for enforcement
664665
const maxBuffer = opts.maxBuffer as number | undefined;
665666

666-
// Args and options passed as JSON strings for transferability
667-
const result = _childProcessSpawnSync(
667+
// Args passed as JSON string for transferability
668+
const jsonResult = _childProcessSpawnSync.applySyncPromise(undefined, [
668669
command,
669670
JSON.stringify(argsArray),
670671
JSON.stringify({ cwd: effectiveCwd, env: opts.env as Record<string, string>, maxBuffer }),
671-
);
672+
]);
673+
const result = JSON.parse(jsonResult) as { stdout: string; stderr: string; code: number; maxBufferExceeded?: boolean };
672674

673675
const stdoutBuf = typeof Buffer !== "undefined" ? Buffer.from(result.stdout) : result.stdout;
674676
const stderrBuf = typeof Buffer !== "undefined" ? Buffer.from(result.stderr) : result.stderr;

packages/secure-exec-core/src/bridge/fs.ts

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fs polyfill module for isolated-vm
1+
// fs polyfill module for the sandbox
22
// This module runs inside the isolate and provides Node.js fs API compatibility
33
// It communicates with the host via the _fs Reference object
44

@@ -1031,12 +1031,12 @@ const fs = {
10311031
try {
10321032
if (encoding) {
10331033
// Text mode - use text read
1034-
const content = _fs.readFile(pathStr);
1034+
const content = _fs.readFile.applySyncPromise(undefined, [pathStr]);
10351035
return content;
10361036
} else {
1037-
// Binary mode - host returns raw Uint8Array via MessagePack bin
1038-
const binaryData = _fs.readFileBinary(pathStr);
1039-
return Buffer.from(binaryData);
1037+
// Binary mode - use binary read with base64 encoding
1038+
const base64Content = _fs.readFileBinary.applySyncPromise(undefined, [pathStr]);
1039+
return Buffer.from(base64Content, "base64");
10401040
}
10411041
} catch (err) {
10421042
const errMsg = (err as Error).message || String(err);
@@ -1079,14 +1079,15 @@ const fs = {
10791079
if (typeof data === "string") {
10801080
// Text mode - use text write
10811081
// Return the result so async callers (fs.promises) can await it.
1082-
return _fs.writeFile(pathStr, data);
1082+
return _fs.writeFile.applySyncPromise(undefined, [pathStr, data]);
10831083
} else if (ArrayBuffer.isView(data)) {
1084-
// Binary mode - send raw Uint8Array via MessagePack bin
1084+
// Binary mode - convert to base64 and use binary write
10851085
const uint8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1086-
return _fs.writeFileBinary(pathStr, uint8);
1086+
const base64 = Buffer.from(uint8).toString("base64");
1087+
return _fs.writeFileBinary.applySyncPromise(undefined, [pathStr, base64]);
10871088
} else {
10881089
// Fallback to text mode
1089-
return _fs.writeFile(pathStr, String(data));
1090+
return _fs.writeFile.applySyncPromise(undefined, [pathStr, String(data)]);
10901091
}
10911092
},
10921093

@@ -1105,9 +1106,9 @@ const fs = {
11051106
readdirSync(path: PathLike, options?: nodeFs.ObjectEncodingOptions & { withFileTypes?: boolean; recursive?: boolean }): string[] | Dirent[] {
11061107
const rawPath = toPathString(path);
11071108
const pathStr = rawPath;
1108-
let entries: Array<{ name: string; isDirectory: boolean }>;
1109+
let entriesJson: string;
11091110
try {
1110-
entries = _fs.readDir(pathStr);
1111+
entriesJson = _fs.readDir.applySyncPromise(undefined, [pathStr]);
11111112
} catch (err) {
11121113
// Convert "entry not found" and similar errors to proper ENOENT
11131114
const errMsg = (err as Error).message || String(err);
@@ -1121,6 +1122,10 @@ const fs = {
11211122
}
11221123
throw err;
11231124
}
1125+
const entries = JSON.parse(entriesJson) as Array<{
1126+
name: string;
1127+
isDirectory: boolean;
1128+
}>;
11241129
if (options?.withFileTypes) {
11251130
return entries.map((e) => new Dirent(e.name, e.isDirectory, rawPath));
11261131
}
@@ -1131,13 +1136,13 @@ const fs = {
11311136
const rawPath = toPathString(path);
11321137
const pathStr = rawPath;
11331138
const recursive = typeof options === "object" ? options?.recursive ?? false : false;
1134-
_fs.mkdir(pathStr, recursive);
1139+
_fs.mkdir.applySyncPromise(undefined, [pathStr, recursive]);
11351140
return recursive ? rawPath : undefined;
11361141
},
11371142

11381143
rmdirSync(path: PathLike, _options?: RmDirOptions): void {
11391144
const pathStr = toPathString(path);
1140-
_fs.rmdir(pathStr);
1145+
_fs.rmdir.applySyncPromise(undefined, [pathStr]);
11411146
},
11421147

11431148
rmSync(path: PathLike, options?: { force?: boolean; recursive?: boolean }): void {
@@ -1175,15 +1180,15 @@ const fs = {
11751180

11761181
existsSync(path: PathLike): boolean {
11771182
const pathStr = toPathString(path);
1178-
return _fs.exists(pathStr);
1183+
return _fs.exists.applySyncPromise(undefined, [pathStr]);
11791184
},
11801185

11811186
statSync(path: PathLike, _options?: nodeFs.StatSyncOptions): Stats {
11821187
const rawPath = toPathString(path);
11831188
const pathStr = rawPath;
1184-
let stat: { mode: number; size: number; isDirectory: boolean; atimeMs: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number };
1189+
let statJson: string;
11851190
try {
1186-
stat = _fs.stat(pathStr);
1191+
statJson = _fs.stat.applySyncPromise(undefined, [pathStr]);
11871192
} catch (err) {
11881193
// Convert various "not found" errors to proper ENOENT
11891194
const errMsg = (err as Error).message || String(err);
@@ -1202,24 +1207,42 @@ const fs = {
12021207
}
12031208
throw err;
12041209
}
1210+
const stat = JSON.parse(statJson) as {
1211+
mode: number;
1212+
size: number;
1213+
atimeMs?: number;
1214+
mtimeMs?: number;
1215+
ctimeMs?: number;
1216+
birthtimeMs?: number;
1217+
};
12051218
return new Stats(stat);
12061219
},
12071220

12081221
lstatSync(path: PathLike, _options?: nodeFs.StatSyncOptions): Stats {
12091222
const pathStr = toPathString(path);
1210-
const stat = bridgeCall(() => _fs.lstat(pathStr), "lstat", pathStr);
1223+
const statJson = bridgeCall(() => _fs.lstat.applySyncPromise(undefined, [pathStr]), "lstat", pathStr);
1224+
const stat = JSON.parse(statJson) as {
1225+
mode: number;
1226+
size: number;
1227+
isDirectory: boolean;
1228+
isSymbolicLink?: boolean;
1229+
atimeMs?: number;
1230+
mtimeMs?: number;
1231+
ctimeMs?: number;
1232+
birthtimeMs?: number;
1233+
};
12111234
return new Stats(stat);
12121235
},
12131236

12141237
unlinkSync(path: PathLike): void {
12151238
const pathStr = toPathString(path);
1216-
_fs.unlink(pathStr);
1239+
_fs.unlink.applySyncPromise(undefined, [pathStr]);
12171240
},
12181241

12191242
renameSync(oldPath: PathLike, newPath: PathLike): void {
12201243
const oldPathStr = toPathString(oldPath);
12211244
const newPathStr = toPathString(newPath);
1222-
_fs.rename(oldPathStr, newPathStr);
1245+
_fs.rename.applySyncPromise(undefined, [oldPathStr, newPathStr]);
12231246
},
12241247

12251248
copyFileSync(src: PathLike, dest: PathLike, _mode?: number): void {
@@ -1550,41 +1573,41 @@ const fs = {
15501573
chmodSync(path: PathLike, mode: Mode): void {
15511574
const pathStr = toPathString(path);
15521575
const modeNum = typeof mode === "string" ? parseInt(mode, 8) : mode;
1553-
bridgeCall(() => _fs.chmod(pathStr, modeNum), "chmod", pathStr);
1576+
bridgeCall(() => _fs.chmod.applySyncPromise(undefined, [pathStr, modeNum]), "chmod", pathStr);
15541577
},
15551578

15561579
chownSync(path: PathLike, uid: number, gid: number): void {
15571580
const pathStr = toPathString(path);
1558-
bridgeCall(() => _fs.chown(pathStr, uid, gid), "chown", pathStr);
1581+
bridgeCall(() => _fs.chown.applySyncPromise(undefined, [pathStr, uid, gid]), "chown", pathStr);
15591582
},
15601583

15611584
linkSync(existingPath: PathLike, newPath: PathLike): void {
15621585
const existingStr = toPathString(existingPath);
15631586
const newStr = toPathString(newPath);
1564-
bridgeCall(() => _fs.link(existingStr, newStr), "link", newStr);
1587+
bridgeCall(() => _fs.link.applySyncPromise(undefined, [existingStr, newStr]), "link", newStr);
15651588
},
15661589

15671590
symlinkSync(target: PathLike, path: PathLike, _type?: string | null): void {
15681591
const targetStr = toPathString(target);
15691592
const pathStr = toPathString(path);
1570-
bridgeCall(() => _fs.symlink(targetStr, pathStr), "symlink", pathStr);
1593+
bridgeCall(() => _fs.symlink.applySyncPromise(undefined, [targetStr, pathStr]), "symlink", pathStr);
15711594
},
15721595

15731596
readlinkSync(path: PathLike, _options?: nodeFs.EncodingOption): string {
15741597
const pathStr = toPathString(path);
1575-
return bridgeCall(() => _fs.readlink(pathStr), "readlink", pathStr);
1598+
return bridgeCall(() => _fs.readlink.applySyncPromise(undefined, [pathStr]), "readlink", pathStr);
15761599
},
15771600

15781601
truncateSync(path: PathLike, len?: number | null): void {
15791602
const pathStr = toPathString(path);
1580-
bridgeCall(() => _fs.truncate(pathStr, len ?? 0), "truncate", pathStr);
1603+
bridgeCall(() => _fs.truncate.applySyncPromise(undefined, [pathStr, len ?? 0]), "truncate", pathStr);
15811604
},
15821605

15831606
utimesSync(path: PathLike, atime: string | number | Date, mtime: string | number | Date): void {
15841607
const pathStr = toPathString(path);
15851608
const atimeNum = typeof atime === "number" ? atime : new Date(atime).getTime() / 1000;
15861609
const mtimeNum = typeof mtime === "number" ? mtime : new Date(mtime).getTime() / 1000;
1587-
bridgeCall(() => _fs.utimes(pathStr, atimeNum, mtimeNum), "utimes", pathStr);
1610+
bridgeCall(() => _fs.utimes.applySyncPromise(undefined, [pathStr, atimeNum, mtimeNum]), "utimes", pathStr);
15881611
},
15891612

15901613
// Async methods - wrap sync methods in callbacks/promises

packages/secure-exec-core/src/bridge/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// This file is compiled to a single JS bundle that gets injected into the isolate
33
//
44
// Each module provides polyfills for Node.js built-in modules that need to
5-
// communicate with the host environment via isolated-vm bridge functions.
5+
// communicate with the host environment via bridge functions.
66

77
// IMPORTANT: Import polyfills FIRST before any other modules!
88
// Some packages (like whatwg-url) use TextEncoder/TextDecoder at module load time.

packages/secure-exec-core/src/bridge/module.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
ResolveModuleBridgeRef,
66
} from "../shared/bridge-contract.js";
77

8-
// Module polyfill for isolated-vm
8+
// Module polyfill for the sandbox
99
// Provides module.createRequire and other module utilities for npm compatibility
1010

1111
// Declare host bridge globals that are set up by setupRequire()
@@ -115,7 +115,10 @@ export function createRequire(filename: string | URL): RequireFunction {
115115
request: string,
116116
_options?: { paths?: string[] }
117117
): string {
118-
const resolved = _resolveModule(request, dirname);
118+
const resolved = _resolveModule.applySyncPromise(undefined, [
119+
request,
120+
dirname,
121+
]);
119122
if (resolved === null) {
120123
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
121124
err.code = "MODULE_NOT_FOUND";
@@ -211,7 +214,10 @@ export class Module {
211214
(moduleRequire as { resolve?: (request: string) => string }).resolve = (
212215
request: string
213216
): string => {
214-
const resolved = _resolveModule(request, this.path);
217+
const resolved = _resolveModule.applySyncPromise(undefined, [
218+
request,
219+
this.path,
220+
]);
215221
if (resolved === null) {
216222
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
217223
err.code = "MODULE_NOT_FOUND";
@@ -252,7 +258,10 @@ export class Module {
252258
_options?: unknown
253259
): string {
254260
const parentDir = parent && parent.path ? parent.path : "/";
255-
const resolved = _resolveModule(request, parentDir);
261+
const resolved = _resolveModule.applySyncPromise(undefined, [
262+
request,
263+
parentDir,
264+
]);
256265
if (resolved === null) {
257266
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
258267
err.code = "MODULE_NOT_FOUND";

0 commit comments

Comments
 (0)