Skip to content

Commit 5d5f90f

Browse files
committed
feat: US-024 - V8 sidecar: use native ESM mode for ESM files (stop converting ESM to CJS)
- Detect ESM files (.mjs, import/export syntax) in kernel-runtime.ts and route them to V8's native module system (run mode) instead of CJS exec - Add `esm` option to ExecOptions for explicit ESM mode selection - Remove transformDynamicImport from async loadFile handler since V8 handles import() natively via dynamic_import_callback (US-023) - Apply env/cwd/stdin overrides in run mode (previously exec-only) - Register HostInitializeImportMetaObjectCallback in Rust sidecar to populate import.meta.url for ESM modules - Use raw (unwrapped) filesystem for module resolution bridge handlers so V8's internal module loading bypasses user-level permissions - Add tests: ESM execution, CJS compatibility, static imports, import.meta.url, dynamic import in ESM mode
1 parent d587d9f commit 5d5f90f

6 files changed

Lines changed: 172 additions & 40 deletions

File tree

native/v8-runtime/src/execution.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,40 @@ fn clear_module_state() {
589589
/// Must be called after isolate creation (not captured in snapshots).
590590
pub fn enable_dynamic_import(isolate: &mut v8::OwnedIsolate) {
591591
isolate.set_host_import_module_dynamically_callback(dynamic_import_callback);
592+
isolate.set_host_initialize_import_meta_object_callback(import_meta_callback);
593+
}
594+
595+
/// V8 HostInitializeImportMetaObjectCallback — populates import.meta for ES modules.
596+
///
597+
/// Sets import.meta.url to "file://<path>" using the module's resolved path
598+
/// from MODULE_RESOLVE_STATE.module_names.
599+
extern "C" fn import_meta_callback(
600+
context: v8::Local<v8::Context>,
601+
module: v8::Local<v8::Module>,
602+
meta: v8::Local<v8::Object>,
603+
) {
604+
// Look up the module's file path from thread-local state
605+
let hash = module.get_identity_hash();
606+
let url = MODULE_RESOLVE_STATE.with(|cell| {
607+
let borrow = cell.borrow();
608+
borrow.as_ref().and_then(|state| {
609+
state.module_names.get(&hash).map(|path| {
610+
if path.starts_with("file://") {
611+
path.clone()
612+
} else {
613+
format!("file://{}", path)
614+
}
615+
})
616+
})
617+
});
618+
619+
if let Some(url) = url {
620+
// SAFETY: callback is invoked within V8 execution scope
621+
let scope = unsafe { &mut v8::CallbackScope::new(context) };
622+
let key = v8::String::new(scope, "url").unwrap();
623+
let val = v8::String::new(scope, &url).unwrap();
624+
meta.create_data_property(scope, key.into(), val.into());
625+
}
592626
}
593627

594628
/// V8 HostImportModuleDynamicallyCallback — called when import() is evaluated.

packages/core/src/shared/api-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export interface ExecOptions {
8282
timingMitigation?: TimingMitigation;
8383
/** Optional streaming hook for console output events */
8484
onStdio?: StdioHook;
85+
/** Use V8 native ESM module mode instead of CJS script mode */
86+
esm?: boolean;
8587
}
8688

8789
export interface ExecResult extends ExecutionStatus {}

packages/nodejs/src/bridge-handlers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,10 +1365,10 @@ export function buildModuleLoadingBridgeHandlers(
13651365
return `const _p = (function(){var module={exports:{}};var exports=module.exports;${code};return module.exports})();\nexport default _p;\n` +
13661366
`for(const[k,v]of Object.entries(_p)){if(k!=='default'&&/^[A-Za-z_$]/.test(k))globalThis['__esm_'+k]=v;}\n`;
13671367
}
1368-
// Regular file — keep ESM source intact for V8 module system
1368+
// Regular file — keep source intact for V8 module system
1369+
// V8 handles import() natively via dynamic_import_callback (US-023)
13691370
const source = await loadFile(p, deps.filesystem);
1370-
if (source === null) return null;
1371-
return transformDynamicImport(source);
1371+
return source;
13721372
};
13731373

13741374
return handlers;

packages/nodejs/src/execution-driver.ts

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ export class NodeExecutionDriver implements RuntimeDriver {
387387

388388
async exec(code: string, options?: ExecOptions): Promise<ExecResult> {
389389
const result = await this.executeInternal({
390-
mode: "exec",
390+
mode: options?.esm ? "run" : "exec",
391391
code,
392392
filePath: options?.filePath,
393393
env: options?.env,
@@ -461,7 +461,9 @@ export class NodeExecutionDriver implements RuntimeDriver {
461461
maxOutputBytes: s.maxOutputBytes,
462462
}),
463463
...buildModuleLoadingBridgeHandlers({
464-
filesystem: s.filesystem,
464+
// Module resolution uses the raw (unwrapped) filesystem — bypasses
465+
// user-level permissions since it's an internal V8 operation
466+
filesystem: this.rawFilesystem ?? s.filesystem,
465467
resolutionCache: s.resolutionCache,
466468
sandboxToHostPath: (p) => {
467469
const rfs = this.rawFilesystem as any;
@@ -796,39 +798,27 @@ function buildPostRestoreScript(
796798
parts.push(getIsolateRuntimeSource("applyTimingMitigationOff"));
797799
}
798800

799-
// Apply execution overrides (env, cwd, stdin) for exec mode
800-
if (mode === "exec") {
801-
if (processConfig.env) {
802-
parts.push(`globalThis.__runtimeProcessEnvOverride = ${JSON.stringify(processConfig.env)};`);
803-
parts.push(getIsolateRuntimeSource("overrideProcessEnv"));
804-
}
805-
if (processConfig.cwd) {
806-
parts.push(`globalThis.__runtimeProcessCwdOverride = ${JSON.stringify(processConfig.cwd)};`);
807-
parts.push(getIsolateRuntimeSource("overrideProcessCwd"));
808-
}
809-
if (bridgeConfig.stdin !== undefined) {
810-
parts.push(`globalThis.__runtimeStdinData = ${JSON.stringify(bridgeConfig.stdin)};`);
811-
parts.push(getIsolateRuntimeSource("setStdinData"));
812-
}
813-
// Set CommonJS globals
814-
parts.push(getIsolateRuntimeSource("initCommonjsModuleGlobals"));
815-
if (filePath) {
816-
const dirname = filePath.includes("/")
817-
? filePath.substring(0, filePath.lastIndexOf("/")) || "/"
818-
: "/";
819-
parts.push(`globalThis.__runtimeCommonJsFileConfig = ${JSON.stringify({ filePath, dirname })};`);
820-
parts.push(getIsolateRuntimeSource("setCommonjsFileGlobals"));
821-
}
822-
} else {
823-
// run mode — still need CommonJS module globals
824-
parts.push(getIsolateRuntimeSource("initCommonjsModuleGlobals"));
825-
if (filePath) {
826-
const dirname = filePath.includes("/")
827-
? filePath.substring(0, filePath.lastIndexOf("/")) || "/"
828-
: "/";
829-
parts.push(`globalThis.__runtimeCommonJsFileConfig = ${JSON.stringify({ filePath, dirname })};`);
830-
parts.push(getIsolateRuntimeSource("setCommonjsFileGlobals"));
831-
}
801+
// Apply execution overrides (env, cwd, stdin) for both exec and run modes
802+
if (processConfig.env) {
803+
parts.push(`globalThis.__runtimeProcessEnvOverride = ${JSON.stringify(processConfig.env)};`);
804+
parts.push(getIsolateRuntimeSource("overrideProcessEnv"));
805+
}
806+
if (processConfig.cwd) {
807+
parts.push(`globalThis.__runtimeProcessCwdOverride = ${JSON.stringify(processConfig.cwd)};`);
808+
parts.push(getIsolateRuntimeSource("overrideProcessCwd"));
809+
}
810+
if (bridgeConfig.stdin !== undefined) {
811+
parts.push(`globalThis.__runtimeStdinData = ${JSON.stringify(bridgeConfig.stdin)};`);
812+
parts.push(getIsolateRuntimeSource("setStdinData"));
813+
}
814+
// Set CommonJS globals (needed in both modes for require() compatibility)
815+
parts.push(getIsolateRuntimeSource("initCommonjsModuleGlobals"));
816+
if (filePath) {
817+
const dirname = filePath.includes("/")
818+
? filePath.substring(0, filePath.lastIndexOf("/")) || "/"
819+
: "/";
820+
parts.push(`globalThis.__runtimeCommonJsFileConfig = ${JSON.stringify({ filePath, dirname })};`);
821+
parts.push(getIsolateRuntimeSource("setCommonjsFileGlobals"));
832822
}
833823

834824
// Apply custom global exposure policy

packages/nodejs/src/kernel-runtime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import { NodeExecutionDriver } from './execution-driver.js';
2323
import { createNodeDriver } from './driver.js';
2424
import type { BindingTree } from './bindings.js';
25+
import { isESM } from '@secure-exec/core/internal/shared/esm-utils';
2526
import {
2627
allowAllChildProcess,
2728
allowAllFs,
@@ -484,12 +485,16 @@ class NodeRuntimeDriver implements RuntimeDriver {
484485
});
485486
this._activeDrivers.set(ctx.pid, executionDriver);
486487

488+
// Detect ESM files and use V8 native module system
489+
const useEsm = isESM(code, filePath);
490+
487491
// Execute with stdout/stderr capture and stdin data
488492
const result = await executionDriver.exec(code, {
489493
filePath,
490494
env: ctx.env,
491495
cwd: ctx.cwd,
492496
stdin: stdinData,
497+
esm: useEsm,
493498
onStdio: (event) => {
494499
const data = new TextEncoder().encode(event.message + '\n');
495500
if (event.channel === 'stdout') {

packages/secure-exec/tests/kernel/bridge-gap-behavior.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import type { Kernel } from '../../../core/src/kernel/index.ts';
1212
import { InMemoryFileSystem } from '../../../browser/src/os-filesystem.ts';
1313
import { createNodeRuntime } from '../../../nodejs/src/kernel-runtime.ts';
1414

15-
async function createNodeKernel(): Promise<{ kernel: Kernel; dispose: () => Promise<void> }> {
15+
async function createNodeKernel(): Promise<{ kernel: Kernel; vfs: InMemoryFileSystem; dispose: () => Promise<void> }> {
1616
const vfs = new InMemoryFileSystem();
1717
const kernel = createKernel({ filesystem: vfs });
1818
await kernel.mount(createNodeRuntime());
19-
return { kernel, dispose: () => kernel.dispose() };
19+
return { kernel, vfs, dispose: () => kernel.dispose() };
2020
}
2121

2222
/** Collect all output from a PTY-backed process spawned via openShell. */
@@ -140,3 +140,104 @@ describe('bridge gap: setRawMode via PTY', () => {
140140
expect(output).toContain('not a TTY');
141141
}, 15_000);
142142
});
143+
144+
// ---------------------------------------------------------------------------
145+
// Native ESM mode (V8 module system)
146+
// ---------------------------------------------------------------------------
147+
148+
describe('native ESM execution via V8 module system', () => {
149+
let ctx: { kernel: Kernel; vfs: InMemoryFileSystem; dispose: () => Promise<void> };
150+
151+
afterEach(async () => {
152+
await ctx?.dispose();
153+
});
154+
155+
it('ESM module with import/export runs correctly via kernel.spawn()', async () => {
156+
ctx = await createNodeKernel();
157+
// Write an ESM file to VFS
158+
await ctx.vfs.writeFile('/app/main.mjs', `
159+
const msg = 'ESM_OK';
160+
console.log(msg);
161+
`);
162+
163+
const stdout: string[] = [];
164+
const proc = ctx.kernel.spawn('node', ['/app/main.mjs'], {
165+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
166+
});
167+
const exitCode = await proc.wait();
168+
169+
expect(exitCode).toBe(0);
170+
expect(stdout.join('')).toContain('ESM_OK');
171+
}, 15_000);
172+
173+
it('CJS module with require() still runs correctly via kernel.spawn()', async () => {
174+
ctx = await createNodeKernel();
175+
// CJS code — no import/export syntax, uses require
176+
const stdout: string[] = [];
177+
const proc = ctx.kernel.spawn('node', ['-e', "const os = require('os'); console.log('CJS_OK:' + os.platform())"], {
178+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
179+
});
180+
const exitCode = await proc.wait();
181+
182+
expect(exitCode).toBe(0);
183+
expect(stdout.join('')).toContain('CJS_OK:');
184+
}, 15_000);
185+
186+
it('ESM file with static import resolves via V8 module_resolve_callback', async () => {
187+
ctx = await createNodeKernel();
188+
// Write two ESM files — main imports from helper
189+
await ctx.vfs.writeFile('/app/helper.mjs', `
190+
export const greeting = 'HELLO_FROM_ESM';
191+
`);
192+
await ctx.vfs.writeFile('/app/main.mjs', `
193+
import { greeting } from './helper.mjs';
194+
console.log(greeting);
195+
`);
196+
197+
const stdout: string[] = [];
198+
const proc = ctx.kernel.spawn('node', ['/app/main.mjs'], {
199+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
200+
});
201+
const exitCode = await proc.wait();
202+
203+
expect(exitCode).toBe(0);
204+
expect(stdout.join('')).toContain('HELLO_FROM_ESM');
205+
}, 15_000);
206+
207+
it('import.meta.url is populated for ESM modules', async () => {
208+
ctx = await createNodeKernel();
209+
await ctx.vfs.writeFile('/app/meta.mjs', `
210+
console.log('META_URL:' + import.meta.url);
211+
`);
212+
213+
const stdout: string[] = [];
214+
const proc = ctx.kernel.spawn('node', ['/app/meta.mjs'], {
215+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
216+
});
217+
const exitCode = await proc.wait();
218+
219+
expect(exitCode).toBe(0);
220+
const output = stdout.join('');
221+
expect(output).toContain('META_URL:file:///app/meta.mjs');
222+
}, 15_000);
223+
224+
it('dynamic import() works in ESM via V8 native callback', async () => {
225+
ctx = await createNodeKernel();
226+
await ctx.vfs.writeFile('/app/dynamic-dep.mjs', `
227+
export const value = 'DYNAMIC_IMPORT_OK';
228+
`);
229+
await ctx.vfs.writeFile('/app/dynamic-main.mjs', `
230+
const mod = await import('./dynamic-dep.mjs');
231+
console.log(mod.value);
232+
`);
233+
234+
const stdout: string[] = [];
235+
const proc = ctx.kernel.spawn('node', ['/app/dynamic-main.mjs'], {
236+
onStdout: (data) => stdout.push(new TextDecoder().decode(data)),
237+
});
238+
const exitCode = await proc.wait();
239+
240+
expect(exitCode).toBe(0);
241+
expect(stdout.join('')).toContain('DYNAMIC_IMPORT_OK');
242+
}, 15_000);
243+
});

0 commit comments

Comments
 (0)