Skip to content
Open
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
97 changes: 91 additions & 6 deletions packages/core/src/kernel/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,99 @@ class KernelImpl implements Kernel {

// Route through shell
const shell = this.commandRegistry.resolve("sh");
if (!shell) {
throw new Error(
"No shell available. Mount a WasmVM runtime to enable exec().",
);
if (shell) {
const proc = this.spawnInternal("sh", ["-c", command], options);
return this.#collectExecResult(proc, options);
}

const proc = this.spawnInternal("sh", ["-c", command], options);
// No shell available. If 'node' is registered (e.g. NodeRuntime mounted),
// fall back to direct node execution — parse command string into node args.
// This makes the README example work out-of-the-box:
// kernel.exec("node -e \"console.log('hello')\"")
const nodeCmd = this.commandRegistry.resolve("node");
if (nodeCmd) {
// Parse command string into individual args (handles quotes)
const args = this.#parseCommandArgs(command);
if (args.length > 0 && args[0] === "node") {
args.shift(); // strip 'node' prefix, keep the rest
const proc = this.spawnInternal("node", args, options);
return this.#collectExecResult(proc, options);
}
}

throw new Error(
"No shell available. Mount a WasmVM runtime to enable exec(), " +
"or mount a runtime that registers the 'node' command and use " +
"`kernel.exec('node -e \"code\"')`.",
);
}

/**
* Parse a command string into individual arguments.
* Handles single quotes, double quotes, and basic shell tokenization.
*/
#parseCommandArgs(command: string): string[] {
const args: string[] = [];
let current = "";
let inSingle = false;
let inDouble = false;
let i = 0;

while (i < command.length) {
const ch = command[i];

if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
current += ch;
}
i++;
} else if (inDouble) {
if (ch === '"') {
inDouble = false;
} else if (ch === "\\" && i + 1 < command.length) {
// Handle escape sequences in double quotes
current += command[i + 1];
i += 2;
} else {
current += ch;
i++;
}
} else {
if (ch === "'") {
inSingle = true;
i++;
} else if (ch === '"') {
inDouble = true;
i++;
} else if (ch === " ") {
if (current.length > 0) {
args.push(current);
current = "";
}
i++;
} else {
current += ch;
i++;
}
}
}

if (current.length > 0) {
args.push(current);
}

return args;
}

/**
* Collect stdout/stderr from a spawned process for exec().
*/
async #collectExecResult(
proc: InternalProcess,
options?: ExecOptions,
): Promise<ExecResult> {
// Write stdin if provided
if (options?.stdin) {
const data =
Expand Down Expand Up @@ -347,7 +432,7 @@ class KernelImpl implements Kernel {
new Promise<number>((_, reject) => {
timer = setTimeout(() => {
// Kill process and detach output callbacks
this.log.warn({ command, timeout: options.timeout }, "exec timeout, sending SIGTERM");
this.log.warn({ timeout: options.timeout }, "exec timeout, sending SIGTERM");
proc.onStdout = null;
proc.onStderr = null;
proc.kill(SIGTERM);
Expand Down
37 changes: 37 additions & 0 deletions packages/core/test/kernel/kernel-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,43 @@ describe("kernel + MockRuntimeDriver integration", () => {
expect(result.stderr).toBe("warn\n");
});

it("exec falls back to node when sh is not available", async () => {
// NodeRuntime only registers 'node', not 'sh'.
// exec() should detect this and route through node directly.
// This is the fix for: https://github.com/rivet-dev/secure-exec/issues/64
const driver = new MockRuntimeDriver(["node"], {
node: {
exitCode: 0,
stdout: "hello from node\n",
stderr: "",
// args are passed as-is; for node -e "code", args = ["-e", "code"]
},
});
({ kernel } = await createTestKernel({ drivers: [driver] }));

const result = await kernel.exec("node -e \"console.log('hello from node')\"");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("hello from node\n");
});

it("exec of node command with single-quoted code", async () => {
const driver = new MockRuntimeDriver(["node"], {
node: { exitCode: 0, stdout: "42\n" },
});
({ kernel } = await createTestKernel({ drivers: [driver] }));

const result = await kernel.exec("node -e 'console.log(42)'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("42\n");
});

it("exec throws descriptive error when neither sh nor node is available", async () => {
// No drivers registered — neither sh nor node
({ kernel } = await createTestKernel({ drivers: [] }));

await expect(kernel.exec("echo hello")).rejects.toThrow("No shell available");
});

it("exec of unknown command throws ENOENT", async () => {
const driver = new MockRuntimeDriver(["sh"], { sh: { exitCode: 0 } });
({ kernel } = await createTestKernel({ drivers: [driver] }));
Expand Down
6 changes: 6 additions & 0 deletions packages/nodejs/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface NodeDriverOptions {
loopbackExemptPorts?: number[];
processConfig?: ProcessConfig;
osConfig?: OSConfig;
/** Include Node.js shims (fs, http, process, Buffer, etc.) on globalThis. Default: true. */
includeNodeShims?: boolean;
}

export interface NodeRuntimeDriverFactoryOptions {
Expand Down Expand Up @@ -260,6 +262,10 @@ export function createNodeDriver(options: NodeDriverOptions = {}): SystemDriver
os: {
...(options.osConfig ?? {}),
},
// @ts-ignore-next-line — internal field used by NodeExecutionDriver to gate bridge shims
...(options.includeNodeShims !== undefined
? { includeNodeShims: options.includeNodeShims }
: {}),
},
};
}
Expand Down
41 changes: 30 additions & 11 deletions packages/nodejs/src/execution-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ interface DriverState {
resolutionCache: ResolutionCache;
onPtySetRawMode?: (mode: boolean) => void;
liveStdinSource?: NodeExecutionDriverOptions["liveStdinSource"];
/** Pre-built bridge code for this driver, based on includeNodeShims setting. */
bridgeCode: string;
/** Whether this driver includes Node.js polyfill shims on globalThis. */
includeNodeShims: boolean;
}

// Shared V8 runtime process — one per Node.js process, lazy-initialized
Expand All @@ -151,8 +155,8 @@ async function getSharedV8Runtime(): Promise<V8Runtime> {
if (sharedV8Runtime?.isAlive) return sharedV8Runtime;
if (sharedV8RuntimePromise) return sharedV8RuntimePromise;

// Build bridge code for snapshot warmup
const bridgeCode = buildFullBridgeCode();
// Build bridge code for snapshot warmup (always with shims for the shared runtime)
const bridgeCode = buildFullBridgeCode(true);

sharedV8RuntimePromise = createV8Runtime({
warmupBridgeCode: bridgeCode,
Expand Down Expand Up @@ -765,10 +769,12 @@ function buildBridgeDispatchShim(): string {
const BRIDGE_DISPATCH_SHIM = buildBridgeDispatchShim();

// Cache assembled bridge code (same across all executions)
let bridgeCodeCache: string | null = null;
// Keyed by includeNodeShims flag so drivers with different settings get correct code
const bridgeCodeCache = new Map<boolean, string>();

function buildFullBridgeCode(): string {
if (bridgeCodeCache) return bridgeCodeCache;
function buildFullBridgeCode(includeNodeShims: boolean = true): string {
const cached = bridgeCodeCache.get(includeNodeShims);
if (cached !== undefined) return cached;

// Assemble the full bridge code IIFE from component scripts.
// Only include code that can run without bridge calls (snapshot phase).
Expand All @@ -778,12 +784,18 @@ function buildFullBridgeCode(): string {
V8_POLYFILLS,
getIsolateRuntimeSource("globalExposureHelpers"),
getInitialBridgeGlobalsSetupCode(),
getRawBridgeCode(),
getBridgeAttachCode(),
];

bridgeCodeCache = parts.join("\n");
return bridgeCodeCache;
// Only include Node.js shims (fs, http, process globals, etc.) when explicitly requested.
// For AI agent use cases, users may want a clean globalThis with no injected polyfills.
if (includeNodeShims) {
parts.push(getRawBridgeCode());
parts.push(getBridgeAttachCode());
}

const code = parts.join("\n");
bridgeCodeCache.set(includeNodeShims, code);
return code;
}

export class NodeExecutionDriver implements RuntimeDriver {
Expand Down Expand Up @@ -853,6 +865,11 @@ export class NodeExecutionDriver implements RuntimeDriver {
osConfig.homedir ??= DEFAULT_SANDBOX_HOME;
osConfig.tmpdir ??= DEFAULT_SANDBOX_TMPDIR;

// Determine whether to include Node.js polyfill shims on globalThis.
// When false, globalThis has no injected fs, http, process, Buffer, etc.
// Useful for AI agent use cases that need a clean global scope.
const includeNodeShims = (options.runtime as any).includeNodeShims ?? true;

const bridgeBase64TransferLimitBytes = normalizePayloadLimit(
options.payloadLimits?.base64TransferBytes,
DEFAULT_BRIDGE_BASE64_TRANSFER_BYTES,
Expand Down Expand Up @@ -893,6 +910,8 @@ export class NodeExecutionDriver implements RuntimeDriver {
resolutionCache: createResolutionCache(),
onPtySetRawMode: options.onPtySetRawMode,
liveStdinSource: options.liveStdinSource,
bridgeCode: buildFullBridgeCode(includeNodeShims),
includeNodeShims,
};

// Validate and flatten bindings once at construction time
Expand Down Expand Up @@ -1284,8 +1303,8 @@ export class NodeExecutionDriver implements RuntimeDriver {
}
}

// Build bridge code with embedded config
const bridgeCode = buildFullBridgeCode();
// Use the pre-built bridge code from constructor (respects includeNodeShims)
const bridgeCode = this.state.bridgeCode;

// Build post-restore script with per-execution config
const bindingKeys = this.flattenedBindings
Expand Down
15 changes: 15 additions & 0 deletions packages/nodejs/src/kernel-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export interface NodeRuntimeOptions {
* before the CWD-based node_modules fallback in the ModuleAccessFileSystem.
*/
packageRoots?: Array<{ hostPath: string; vmPath: string }>;
/**
* Include Node.js polyfill shims (fs, http, process, Buffer, etc.) on globalThis.
*
* When false: globalThis is clean — useful for AI agents that need full
* control over the global scope without any injected Node.js globals.
*
* You can still access these modules via `require('fs')` or `await import('fs')`
* when the host filesystem is accessible via permissions.
*
* Default: true (include shims, for backward compatibility).
*/
includeNodeShims?: boolean;
}

const allowKernelProcSelfRead: Pick<Permissions, 'fs'> = {
Expand Down Expand Up @@ -409,6 +421,7 @@ class NodeRuntimeDriver implements RuntimeDriver {
private _loopbackExemptPorts?: number[];
private _moduleAccessCwd?: string;
private _packageRoots?: Array<{ hostPath: string; vmPath: string }>;
private _includeNodeShims: boolean;

constructor(options?: NodeRuntimeOptions) {
this._memoryLimit = options?.memoryLimit ?? 128;
Expand All @@ -417,6 +430,7 @@ class NodeRuntimeDriver implements RuntimeDriver {
this._loopbackExemptPorts = options?.loopbackExemptPorts;
this._moduleAccessCwd = options?.moduleAccessCwd;
this._packageRoots = options?.packageRoots;
this._includeNodeShims = options?.includeNodeShims ?? true;
}

async init(kernel: KernelInterface): Promise<void> {
Expand Down Expand Up @@ -724,6 +738,7 @@ class NodeRuntimeDriver implements RuntimeDriver {
homedir: ctx.env.HOME || '/root',
tmpdir: ctx.env.TMPDIR || '/tmp',
},
includeNodeShims: this._includeNodeShims,
});

// Wire PTY raw mode callback when stdin is a terminal
Expand Down
55 changes: 55 additions & 0 deletions packages/nodejs/test/kernel-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,3 +969,58 @@ describe('Node RuntimeDriver', () => {
}, 10_000);
});
});

describe('includeNodeShims option', () => {
let kernel: Kernel;

afterEach(async () => {
await kernel?.dispose();
});

it('globalThis.fs is undefined when includeNodeShims is false', async () => {
// With includeNodeShims: false, the bridge does NOT inject fs/http/etc.
// onto globalThis. This is useful for AI agents that need a clean scope.
const vfs = new SimpleVFS();
kernel = createKernel({ filesystem: vfs as any });
await kernel.mount(createNodeRuntime({ includeNodeShims: false }));

// Use exec() with node fallback (fixes #64 � exec falls back to node
// when sh is not registered)
const result = await kernel.exec(

ode -e "console.log(typeof fs)",
);

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('undefined');
});

it('globalThis.fs is an object when includeNodeShims is true (default)', async () => {
// Default behavior: bridge injects fs, http, process, Buffer etc. onto globalThis.
const vfs = new SimpleVFS();
kernel = createKernel({ filesystem: vfs as any });
await kernel.mount(createNodeRuntime({ includeNodeShims: true }));

const result = await kernel.exec(

ode -e "console.log(typeof fs)",
);

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('object');
});

it('exec with includeNodeShims=false still works via node fallback', async () => {
const vfs = new SimpleVFS();
kernel = createKernel({ filesystem: vfs as any });
await kernel.mount(createNodeRuntime({ includeNodeShims: false }));

const result = await kernel.exec(

ode -e "console.log('hello from no-shims runtime')",
);

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('hello from no-shims runtime');
});
});