diff --git a/packages/core/src/kernel/kernel.ts b/packages/core/src/kernel/kernel.ts index fbae41b3..383aa919 100644 --- a/packages/core/src/kernel/kernel.ts +++ b/packages/core/src/kernel/kernel.ts @@ -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 { // Write stdin if provided if (options?.stdin) { const data = @@ -347,7 +432,7 @@ class KernelImpl implements Kernel { new Promise((_, 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); diff --git a/packages/core/test/kernel/kernel-integration.test.ts b/packages/core/test/kernel/kernel-integration.test.ts index 411cccad..0835f2ff 100644 --- a/packages/core/test/kernel/kernel-integration.test.ts +++ b/packages/core/test/kernel/kernel-integration.test.ts @@ -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] })); diff --git a/packages/nodejs/src/driver.ts b/packages/nodejs/src/driver.ts index ee9f1a27..40d40196 100644 --- a/packages/nodejs/src/driver.ts +++ b/packages/nodejs/src/driver.ts @@ -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 { @@ -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 } + : {}), }, }; } diff --git a/packages/nodejs/src/execution-driver.ts b/packages/nodejs/src/execution-driver.ts index 0f8e6dbe..b940c0b8 100644 --- a/packages/nodejs/src/execution-driver.ts +++ b/packages/nodejs/src/execution-driver.ts @@ -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 @@ -151,8 +155,8 @@ async function getSharedV8Runtime(): Promise { 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, @@ -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(); -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). @@ -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 { @@ -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, @@ -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 @@ -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 diff --git a/packages/nodejs/src/kernel-runtime.ts b/packages/nodejs/src/kernel-runtime.ts index c8634977..f6f94659 100644 --- a/packages/nodejs/src/kernel-runtime.ts +++ b/packages/nodejs/src/kernel-runtime.ts @@ -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 = { @@ -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; @@ -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 { @@ -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 diff --git a/packages/nodejs/test/kernel-runtime.test.ts b/packages/nodejs/test/kernel-runtime.test.ts index f66d2d12..97bddaf6 100644 --- a/packages/nodejs/test/kernel-runtime.test.ts +++ b/packages/nodejs/test/kernel-runtime.test.ts @@ -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'); + }); + });