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
6 changes: 6 additions & 0 deletions docs-internal/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ Priority order is:
- Reads >1MB silently truncate; should return EIO.
- Files: `packages/runtime/wasmvm/src/syscall-rpc.ts`

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

## Priority 1: Compatibility and API Coverage

- [ ] Fix `v8.serialize` and `v8.deserialize` to use V8 structured serialization semantics.
Expand Down
2 changes: 1 addition & 1 deletion packages/secure-exec-browser/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function revivePermissions(serialized?: SerializedPermissions): Permissions | un

/**
* Wrap a sync function in the bridge calling convention (`applySync`) so
* bridge code can call it the same way it calls isolated-vm References.
* bridge code can call it the same way it calls bridge References.
*/
function makeApplySync<TArgs extends unknown[], TResult>(
fn: (...args: TArgs) => TResult,
Expand Down
1,003 changes: 997 additions & 6 deletions packages/secure-exec-core/isolate-runtime/src/inject/require-setup.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ const __dynamicImportHandler = async function (
const allowRequireFallback =
request.endsWith(".cjs") || request.endsWith(".json");

// V8 path returns source code (string); old ivm path returned namespace objects.
// Cast is safe — this handler is only active in the legacy ivm codepath.
const source = await globalThis._dynamicImport(request, referrer);

if (source !== null) {
return source as unknown as Record<string, unknown>;
const namespace = await globalThis._dynamicImport.apply(
undefined,
[request, referrer],
{ result: { promise: true } },
);

if (namespace !== null) {
return namespace;
}

if (!allowRequireFallback) {
Expand Down
2 changes: 1 addition & 1 deletion packages/secure-exec-core/src/bridge/active-handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { exposeCustomGlobal } from "../shared/global-exposure.js";
/**
* Active Handles: Mechanism to keep the sandbox alive for async operations.
*
* isolated-vm doesn't have an event loop, so async callbacks (like child process
* The V8 isolate doesn't have an event loop, so async callbacks (like child process
* events) would never fire because the sandbox exits immediately after synchronous
* code finishes. This module tracks active handles and provides a promise that
* resolves when all handles complete.
Expand Down
26 changes: 14 additions & 12 deletions packages/secure-exec-core/src/bridge/child-process.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// child_process module polyfill for isolated-vm
// child_process module polyfill for the sandbox
// Provides Node.js child_process module emulation that bridges to host
//
// Uses the active handles mechanism to keep the sandbox alive while child
Expand Down Expand Up @@ -496,12 +496,13 @@ function execSync(
// Default maxBuffer 1MB (Node.js convention)
const maxBuffer = opts.maxBuffer ?? 1024 * 1024;

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

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

// Streaming mode - spawn immediately
const sessionId = _childProcessSpawnStart(
const sessionId = _childProcessSpawnStart.applySync(undefined, [
command,
JSON.stringify(argsArray),
JSON.stringify({ cwd: effectiveCwd, env: opts.env }),
);
]);

activeChildren.set(sessionId, child);

Expand All @@ -572,13 +573,13 @@ function spawn(
if (typeof _childProcessStdinWrite === "undefined") return false;
const bytes =
typeof data === "string" ? new TextEncoder().encode(data) : (data as Uint8Array);
_childProcessStdinWrite(sessionId, bytes);
_childProcessStdinWrite.applySync(undefined, [sessionId, bytes]);
return true;
};

child.stdin.end = (): void => {
if (typeof _childProcessStdinClose !== "undefined") {
_childProcessStdinClose(sessionId);
_childProcessStdinClose.applySync(undefined, [sessionId]);
}
child.stdin.writable = false;
};
Expand All @@ -592,7 +593,7 @@ function spawn(
: signal === "SIGINT" || signal === 2
? 2
: 15;
_childProcessKill(sessionId, sig);
_childProcessKill.applySync(undefined, [sessionId, sig]);
child.killed = true;
child.signalCode = (
typeof signal === "string" ? signal : "SIGTERM"
Expand Down Expand Up @@ -663,12 +664,13 @@ function spawnSync(
// Pass maxBuffer through to host for enforcement
const maxBuffer = opts.maxBuffer as number | undefined;

// Args and options passed as JSON strings for transferability
const result = _childProcessSpawnSync(
// Args passed as JSON string for transferability
const jsonResult = _childProcessSpawnSync.applySyncPromise(undefined, [
command,
JSON.stringify(argsArray),
JSON.stringify({ cwd: effectiveCwd, env: opts.env as Record<string, string>, maxBuffer }),
);
]);
const result = JSON.parse(jsonResult) as { stdout: string; stderr: string; code: number; maxBufferExceeded?: boolean };

const stdoutBuf = typeof Buffer !== "undefined" ? Buffer.from(result.stdout) : result.stdout;
const stderrBuf = typeof Buffer !== "undefined" ? Buffer.from(result.stderr) : result.stderr;
Expand Down
75 changes: 49 additions & 26 deletions packages/secure-exec-core/src/bridge/fs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// fs polyfill module for isolated-vm
// fs polyfill module for the sandbox
// This module runs inside the isolate and provides Node.js fs API compatibility
// It communicates with the host via the _fs Reference object

Expand Down Expand Up @@ -1031,12 +1031,12 @@ const fs = {
try {
if (encoding) {
// Text mode - use text read
const content = _fs.readFile(pathStr);
const content = _fs.readFile.applySyncPromise(undefined, [pathStr]);
return content;
} else {
// Binary mode - host returns raw Uint8Array via MessagePack bin
const binaryData = _fs.readFileBinary(pathStr);
return Buffer.from(binaryData);
// Binary mode - use binary read with base64 encoding
const base64Content = _fs.readFileBinary.applySyncPromise(undefined, [pathStr]);
return Buffer.from(base64Content, "base64");
}
} catch (err) {
const errMsg = (err as Error).message || String(err);
Expand Down Expand Up @@ -1079,14 +1079,15 @@ const fs = {
if (typeof data === "string") {
// Text mode - use text write
// Return the result so async callers (fs.promises) can await it.
return _fs.writeFile(pathStr, data);
return _fs.writeFile.applySyncPromise(undefined, [pathStr, data]);
} else if (ArrayBuffer.isView(data)) {
// Binary mode - send raw Uint8Array via MessagePack bin
// Binary mode - convert to base64 and use binary write
const uint8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
return _fs.writeFileBinary(pathStr, uint8);
const base64 = Buffer.from(uint8).toString("base64");
return _fs.writeFileBinary.applySyncPromise(undefined, [pathStr, base64]);
} else {
// Fallback to text mode
return _fs.writeFile(pathStr, String(data));
return _fs.writeFile.applySyncPromise(undefined, [pathStr, String(data)]);
}
},

Expand All @@ -1105,9 +1106,9 @@ const fs = {
readdirSync(path: PathLike, options?: nodeFs.ObjectEncodingOptions & { withFileTypes?: boolean; recursive?: boolean }): string[] | Dirent[] {
const rawPath = toPathString(path);
const pathStr = rawPath;
let entries: Array<{ name: string; isDirectory: boolean }>;
let entriesJson: string;
try {
entries = _fs.readDir(pathStr);
entriesJson = _fs.readDir.applySyncPromise(undefined, [pathStr]);
} catch (err) {
// Convert "entry not found" and similar errors to proper ENOENT
const errMsg = (err as Error).message || String(err);
Expand All @@ -1121,6 +1122,10 @@ const fs = {
}
throw err;
}
const entries = JSON.parse(entriesJson) as Array<{
name: string;
isDirectory: boolean;
}>;
if (options?.withFileTypes) {
return entries.map((e) => new Dirent(e.name, e.isDirectory, rawPath));
}
Expand All @@ -1131,13 +1136,13 @@ const fs = {
const rawPath = toPathString(path);
const pathStr = rawPath;
const recursive = typeof options === "object" ? options?.recursive ?? false : false;
_fs.mkdir(pathStr, recursive);
_fs.mkdir.applySyncPromise(undefined, [pathStr, recursive]);
return recursive ? rawPath : undefined;
},

rmdirSync(path: PathLike, _options?: RmDirOptions): void {
const pathStr = toPathString(path);
_fs.rmdir(pathStr);
_fs.rmdir.applySyncPromise(undefined, [pathStr]);
},

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

existsSync(path: PathLike): boolean {
const pathStr = toPathString(path);
return _fs.exists(pathStr);
return _fs.exists.applySyncPromise(undefined, [pathStr]);
},

statSync(path: PathLike, _options?: nodeFs.StatSyncOptions): Stats {
const rawPath = toPathString(path);
const pathStr = rawPath;
let stat: { mode: number; size: number; isDirectory: boolean; atimeMs: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number };
let statJson: string;
try {
stat = _fs.stat(pathStr);
statJson = _fs.stat.applySyncPromise(undefined, [pathStr]);
} catch (err) {
// Convert various "not found" errors to proper ENOENT
const errMsg = (err as Error).message || String(err);
Expand All @@ -1202,24 +1207,42 @@ const fs = {
}
throw err;
}
const stat = JSON.parse(statJson) as {
mode: number;
size: number;
atimeMs?: number;
mtimeMs?: number;
ctimeMs?: number;
birthtimeMs?: number;
};
return new Stats(stat);
},

lstatSync(path: PathLike, _options?: nodeFs.StatSyncOptions): Stats {
const pathStr = toPathString(path);
const stat = bridgeCall(() => _fs.lstat(pathStr), "lstat", pathStr);
const statJson = bridgeCall(() => _fs.lstat.applySyncPromise(undefined, [pathStr]), "lstat", pathStr);
const stat = JSON.parse(statJson) as {
mode: number;
size: number;
isDirectory: boolean;
isSymbolicLink?: boolean;
atimeMs?: number;
mtimeMs?: number;
ctimeMs?: number;
birthtimeMs?: number;
};
return new Stats(stat);
},

unlinkSync(path: PathLike): void {
const pathStr = toPathString(path);
_fs.unlink(pathStr);
_fs.unlink.applySyncPromise(undefined, [pathStr]);
},

renameSync(oldPath: PathLike, newPath: PathLike): void {
const oldPathStr = toPathString(oldPath);
const newPathStr = toPathString(newPath);
_fs.rename(oldPathStr, newPathStr);
_fs.rename.applySyncPromise(undefined, [oldPathStr, newPathStr]);
},

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

chownSync(path: PathLike, uid: number, gid: number): void {
const pathStr = toPathString(path);
bridgeCall(() => _fs.chown(pathStr, uid, gid), "chown", pathStr);
bridgeCall(() => _fs.chown.applySyncPromise(undefined, [pathStr, uid, gid]), "chown", pathStr);
},

linkSync(existingPath: PathLike, newPath: PathLike): void {
const existingStr = toPathString(existingPath);
const newStr = toPathString(newPath);
bridgeCall(() => _fs.link(existingStr, newStr), "link", newStr);
bridgeCall(() => _fs.link.applySyncPromise(undefined, [existingStr, newStr]), "link", newStr);
},

symlinkSync(target: PathLike, path: PathLike, _type?: string | null): void {
const targetStr = toPathString(target);
const pathStr = toPathString(path);
bridgeCall(() => _fs.symlink(targetStr, pathStr), "symlink", pathStr);
bridgeCall(() => _fs.symlink.applySyncPromise(undefined, [targetStr, pathStr]), "symlink", pathStr);
},

readlinkSync(path: PathLike, _options?: nodeFs.EncodingOption): string {
const pathStr = toPathString(path);
return bridgeCall(() => _fs.readlink(pathStr), "readlink", pathStr);
return bridgeCall(() => _fs.readlink.applySyncPromise(undefined, [pathStr]), "readlink", pathStr);
},

truncateSync(path: PathLike, len?: number | null): void {
const pathStr = toPathString(path);
bridgeCall(() => _fs.truncate(pathStr, len ?? 0), "truncate", pathStr);
bridgeCall(() => _fs.truncate.applySyncPromise(undefined, [pathStr, len ?? 0]), "truncate", pathStr);
},

utimesSync(path: PathLike, atime: string | number | Date, mtime: string | number | Date): void {
const pathStr = toPathString(path);
const atimeNum = typeof atime === "number" ? atime : new Date(atime).getTime() / 1000;
const mtimeNum = typeof mtime === "number" ? mtime : new Date(mtime).getTime() / 1000;
bridgeCall(() => _fs.utimes(pathStr, atimeNum, mtimeNum), "utimes", pathStr);
bridgeCall(() => _fs.utimes.applySyncPromise(undefined, [pathStr, atimeNum, mtimeNum]), "utimes", pathStr);
},

// Async methods - wrap sync methods in callbacks/promises
Expand Down
2 changes: 1 addition & 1 deletion packages/secure-exec-core/src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// This file is compiled to a single JS bundle that gets injected into the isolate
//
// Each module provides polyfills for Node.js built-in modules that need to
// communicate with the host environment via isolated-vm bridge functions.
// communicate with the host environment via bridge functions.

// IMPORTANT: Import polyfills FIRST before any other modules!
// Some packages (like whatwg-url) use TextEncoder/TextDecoder at module load time.
Expand Down
17 changes: 13 additions & 4 deletions packages/secure-exec-core/src/bridge/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ResolveModuleBridgeRef,
} from "../shared/bridge-contract.js";

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

// Declare host bridge globals that are set up by setupRequire()
Expand Down Expand Up @@ -115,7 +115,10 @@ export function createRequire(filename: string | URL): RequireFunction {
request: string,
_options?: { paths?: string[] }
): string {
const resolved = _resolveModule(request, dirname);
const resolved = _resolveModule.applySyncPromise(undefined, [
request,
dirname,
]);
if (resolved === null) {
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
err.code = "MODULE_NOT_FOUND";
Expand Down Expand Up @@ -211,7 +214,10 @@ export class Module {
(moduleRequire as { resolve?: (request: string) => string }).resolve = (
request: string
): string => {
const resolved = _resolveModule(request, this.path);
const resolved = _resolveModule.applySyncPromise(undefined, [
request,
this.path,
]);
if (resolved === null) {
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
err.code = "MODULE_NOT_FOUND";
Expand Down Expand Up @@ -252,7 +258,10 @@ export class Module {
_options?: unknown
): string {
const parentDir = parent && parent.path ? parent.path : "/";
const resolved = _resolveModule(request, parentDir);
const resolved = _resolveModule.applySyncPromise(undefined, [
request,
parentDir,
]);
if (resolved === null) {
const err = new Error("Cannot find module '" + request + "'") as NodeJS.ErrnoException;
err.code = "MODULE_NOT_FOUND";
Expand Down
Loading
Loading