Skip to content

Commit bc4eaff

Browse files
NathanFlurryclaude
andcommitted
feat: US-023 - Mock LLM server and Pi headless tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6906494 commit bc4eaff

5 files changed

Lines changed: 364 additions & 403 deletions

File tree

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ const __dynamicImportHandler = async function (
2020
typeof fromPath === "string" && fromPath.length > 0
2121
? fromPath
2222
: __fallbackReferrer;
23-
const allowRequireFallback =
24-
request.endsWith(".cjs") || request.endsWith(".json");
25-
2623
const namespace = await globalThis._dynamicImport.apply(
2724
undefined,
2825
[request, referrer],
@@ -33,10 +30,8 @@ const __dynamicImportHandler = async function (
3330
return namespace;
3431
}
3532

36-
if (!allowRequireFallback) {
37-
throw new Error("Cannot find module '" + request + "'");
38-
}
39-
33+
// Always fall back to require() — handles both CJS packages and ESM
34+
// packages (the bridge converts ESM source to CJS at load time).
4035
const runtimeRequire = globalThis.require;
4136
if (typeof runtimeRequire !== "function") {
4237
throw new Error("Cannot find module '" + request + "'");

packages/secure-exec-core/src/generated/isolate-runtime.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/secure-exec-nodejs/src/bridge-handlers.ts

Lines changed: 237 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
import * as net from "node:net";
77
import * as tls from "node:tls";
8-
import { readFileSync } from "node:fs";
8+
import { readFileSync, realpathSync, existsSync } from "node:fs";
9+
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from "node:path";
910
import { createRequire } from "node:module";
1011
import {
1112
randomFillSync,
@@ -34,8 +35,9 @@ import {
3435
} from "@secure-exec/core";
3536
import { normalizeBuiltinSpecifier } from "./builtin-modules.js";
3637
import { resolveModule, loadFile } from "./package-bundler.js";
37-
import { transformDynamicImport } from "@secure-exec/core/internal/shared/esm-utils";
38+
import { transformDynamicImport, isESM } from "@secure-exec/core/internal/shared/esm-utils";
3839
import { bundlePolyfill, hasPolyfill } from "./polyfills.js";
40+
import { getStaticBuiltinWrapperSource, getEmptyBuiltinESMWrapper } from "./esm-compiler.js";
3941
import {
4042
checkBridgeBudget,
4143
assertPayloadByteLength,
@@ -954,6 +956,169 @@ export interface ModuleResolutionBridgeDeps {
954956
hostToSandboxPath: (hostPath: string) => string;
955957
}
956958

959+
/**
960+
* Convert ESM source to CJS-compatible code for require() loading.
961+
* Handles import declarations, export declarations, and re-exports.
962+
*/
963+
/** Strip // and /* comments from an export/import list string. */
964+
function stripComments(s: string): string {
965+
return s.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
966+
}
967+
968+
function convertEsmToCjs(source: string, filePath: string): string {
969+
if (!isESM(source, filePath)) return source;
970+
971+
let code = source;
972+
973+
// Remove const __filename/dirname declarations (already provided by CJS wrapper)
974+
code = code.replace(/^\s*(?:const|let|var)\s+__filename\s*=\s*[^;]+;?\s*$/gm, "// __filename provided by CJS wrapper");
975+
code = code.replace(/^\s*(?:const|let|var)\s+__dirname\s*=\s*[^;]+;?\s*$/gm, "// __dirname provided by CJS wrapper");
976+
977+
// import X from 'Y' → const X = require('Y')
978+
code = code.replace(
979+
/^\s*import\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?/gm,
980+
"const $1 = (function(m) { return m && m.__esModule ? m.default : m; })(require('$2'));",
981+
);
982+
983+
// import { a, b as c } from 'Y' → const { a, b: c } = require('Y')
984+
code = code.replace(
985+
/^\s*import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?/gm,
986+
(_match, imports: string, mod: string) => {
987+
const mapped = stripComments(imports).split(",").map((s: string) => {
988+
const t = s.trim();
989+
if (!t) return null;
990+
const parts = t.split(/\s+as\s+/);
991+
return parts.length === 2 ? `${parts[0].trim()}: ${parts[1].trim()}` : t;
992+
}).filter(Boolean).join(", ");
993+
return `const { ${mapped} } = require('${mod}');`;
994+
},
995+
);
996+
997+
// import * as X from 'Y' → const X = require('Y')
998+
code = code.replace(
999+
/^\s*import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]\s*;?/gm,
1000+
"const $1 = require('$2');",
1001+
);
1002+
1003+
// Side-effect imports: import 'Y' → require('Y')
1004+
code = code.replace(
1005+
/^\s*import\s+['"]([^'"]+)['"]\s*;?/gm,
1006+
"require('$1');",
1007+
);
1008+
1009+
// export { a, b } from 'Y' → re-export
1010+
code = code.replace(
1011+
/^\s*export\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?/gm,
1012+
(_match, exports: string, mod: string) => {
1013+
return stripComments(exports).split(",").map((s: string) => {
1014+
const t = s.trim();
1015+
if (!t) return "";
1016+
const parts = t.split(/\s+as\s+/);
1017+
const local = parts[0].trim();
1018+
const exported = parts.length === 2 ? parts[1].trim() : local;
1019+
return `Object.defineProperty(exports, '${exported}', { get: () => require('${mod}').${local}, enumerable: true });`;
1020+
}).filter(Boolean).join("\n");
1021+
},
1022+
);
1023+
1024+
// export * from 'Y'
1025+
code = code.replace(
1026+
/^\s*export\s+\*\s+from\s+['"]([^'"]+)['"]\s*;?/gm,
1027+
"Object.assign(exports, require('$1'));",
1028+
);
1029+
1030+
// export default X → module.exports.default = X
1031+
code = code.replace(
1032+
/^\s*export\s+default\s+/gm,
1033+
"module.exports.default = ",
1034+
);
1035+
1036+
// export const/let/var X = ... → const/let/var X = ...; exports.X = X;
1037+
code = code.replace(
1038+
/^\s*export\s+(const|let|var)\s+(\w+)\s*=/gm,
1039+
"$1 $2 =",
1040+
);
1041+
// Capture the names separately to add exports at the end
1042+
const exportedVars: string[] = [];
1043+
for (const m of source.matchAll(/^\s*export\s+(?:const|let|var)\s+(\w+)\s*=/gm)) {
1044+
exportedVars.push(m[1]);
1045+
}
1046+
1047+
// export function X(...) → function X(...); exports.X = X;
1048+
code = code.replace(
1049+
/^\s*export\s+function\s+(\w+)/gm,
1050+
"function $1",
1051+
);
1052+
for (const m of source.matchAll(/^\s*export\s+function\s+(\w+)/gm)) {
1053+
exportedVars.push(m[1]);
1054+
}
1055+
1056+
// export class X → class X; exports.X = X;
1057+
code = code.replace(
1058+
/^\s*export\s+class\s+(\w+)/gm,
1059+
"class $1",
1060+
);
1061+
for (const m of source.matchAll(/^\s*export\s+class\s+(\w+)/gm)) {
1062+
exportedVars.push(m[1]);
1063+
}
1064+
1065+
// export { a, b } (local re-export without from)
1066+
code = code.replace(
1067+
/^\s*export\s+\{([^}]+)\}\s*;?/gm,
1068+
(_match, exports: string) => {
1069+
return stripComments(exports).split(",").map((s: string) => {
1070+
const t = s.trim();
1071+
if (!t) return "";
1072+
const parts = t.split(/\s+as\s+/);
1073+
const local = parts[0].trim();
1074+
const exported = parts.length === 2 ? parts[1].trim() : local;
1075+
return `Object.defineProperty(exports, '${exported}', { get: () => ${local}, enumerable: true });`;
1076+
}).filter(Boolean).join("\n");
1077+
},
1078+
);
1079+
1080+
// Append named exports for exported vars/functions/classes
1081+
if (exportedVars.length > 0) {
1082+
const lines = exportedVars.map(
1083+
(name) => `Object.defineProperty(exports, '${name}', { get: () => ${name}, enumerable: true });`,
1084+
);
1085+
code += "\n" + lines.join("\n");
1086+
}
1087+
1088+
return code;
1089+
}
1090+
1091+
/**
1092+
* Resolve a package specifier by walking up directories and reading package.json exports.
1093+
* Handles both root imports ('pkg') and subpath imports ('pkg/sub').
1094+
*/
1095+
function resolvePackageExport(req: string, startDir: string): string | null {
1096+
// Split into package name and subpath
1097+
const parts = req.startsWith("@") ? req.split("/") : [req.split("/")[0], ...req.split("/").slice(1)];
1098+
const pkgName = req.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
1099+
const subpath = req.startsWith("@")
1100+
? (parts.length > 2 ? "./" + parts.slice(2).join("/") : ".")
1101+
: (parts.length > 1 ? "./" + parts.slice(1).join("/") : ".");
1102+
1103+
let cur = startDir;
1104+
while (cur !== pathDirname(cur)) {
1105+
const pkgJsonPath = pathJoin(cur, "node_modules", ...pkgName.split("/"), "package.json");
1106+
if (existsSync(pkgJsonPath)) {
1107+
const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
1108+
let entry: string | undefined;
1109+
if (pkg.exports) {
1110+
const exportEntry = pkg.exports[subpath];
1111+
if (typeof exportEntry === "string") entry = exportEntry;
1112+
else if (exportEntry) entry = exportEntry.import ?? exportEntry.default;
1113+
}
1114+
if (!entry && subpath === ".") entry = pkg.main;
1115+
if (entry) return pathResolve(pathDirname(pkgJsonPath), entry);
1116+
}
1117+
cur = pathDirname(cur);
1118+
}
1119+
return null;
1120+
}
1121+
9571122
const hostRequire = createRequire(import.meta.url);
9581123

9591124
/**
@@ -971,6 +1136,7 @@ export function buildModuleResolutionBridgeHandlers(
9711136
const K = HOST_BRIDGE_GLOBAL_KEYS;
9721137

9731138
// Sync require.resolve — translates sandbox paths and uses Node.js resolution.
1139+
// Falls back to realpath + manual package.json resolution for pnpm/ESM packages.
9741140
handlers[K.resolveModuleSync] = (request: unknown, fromDir: unknown) => {
9751141
const req = String(request);
9761142

@@ -982,23 +1148,38 @@ export function buildModuleResolutionBridgeHandlers(
9821148
const sandboxDir = String(fromDir);
9831149
const hostDir = deps.sandboxToHostPath(sandboxDir) ?? sandboxDir;
9841150

1151+
// Try require.resolve first
9851152
try {
9861153
const resolved = hostRequire.resolve(req, { paths: [hostDir] });
987-
// Translate resolved host path back to sandbox path
9881154
return deps.hostToSandboxPath(resolved);
989-
} catch {
990-
return null;
991-
}
1155+
} catch { /* CJS resolution failed */ }
1156+
1157+
// Fallback: follow symlinks and try ESM-compatible resolution
1158+
try {
1159+
let realDir: string;
1160+
try { realDir = realpathSync(hostDir); } catch { realDir = hostDir; }
1161+
// Try require.resolve from real path
1162+
try {
1163+
const resolved = hostRequire.resolve(req, { paths: [realDir] });
1164+
return deps.hostToSandboxPath(resolved);
1165+
} catch { /* ESM-only, manual resolution */ }
1166+
// Manual package.json resolution for ESM packages
1167+
const resolved = resolvePackageExport(req, realDir);
1168+
if (resolved) return deps.hostToSandboxPath(resolved);
1169+
} catch { /* fallback failed */ }
1170+
return null;
9921171
};
9931172

9941173
// Sync file read — translates sandbox path and reads via readFileSync.
995-
// Also transforms dynamic import() calls for V8 compatibility.
1174+
// Transforms dynamic import() to __dynamicImport() and converts ESM to CJS
1175+
// for npm packages so require() can load ESM-only dependencies.
9961176
handlers[K.loadFileSync] = (filePath: unknown) => {
9971177
const sandboxPath = String(filePath);
9981178
const hostPath = deps.sandboxToHostPath(sandboxPath) ?? sandboxPath;
9991179

10001180
try {
1001-
const source = readFileSync(hostPath, "utf-8");
1181+
let source = readFileSync(hostPath, "utf-8");
1182+
source = convertEsmToCjs(source, hostPath);
10021183
return transformDynamicImport(source);
10031184
} catch {
10041185
return null;
@@ -1081,6 +1262,8 @@ export function buildConsoleBridgeHandlers(deps: ConsoleBridgeDeps): BridgeHandl
10811262
export interface ModuleLoadingBridgeDeps {
10821263
filesystem: VirtualFileSystem;
10831264
resolutionCache: ResolutionCache;
1265+
/** Convert sandbox path to host path for pnpm/symlink resolution fallback. */
1266+
sandboxToHostPath?: (sandboxPath: string) => string | null;
10841267
}
10851268

10861269
/** Build module loading bridge handlers (loadPolyfill, resolveModule, loadFile). */
@@ -1131,16 +1314,59 @@ export function buildModuleLoadingBridgeHandlers(
11311314
};
11321315

11331316
// Async module path resolution via VFS
1317+
// V8 ESM module resolve sends the full file path as referrer, not a directory.
1318+
// Extract dirname when the referrer looks like a file path.
1319+
// Falls back to Node.js require.resolve() with realpath for pnpm compatibility.
11341320
handlers[K.resolveModule] = async (request: unknown, fromDir: unknown): Promise<string | null> => {
11351321
const req = String(request);
11361322
const builtin = normalizeBuiltinSpecifier(req);
11371323
if (builtin) return builtin;
1138-
return resolveModule(req, String(fromDir), deps.filesystem, "require", deps.resolutionCache);
1324+
let dir = String(fromDir);
1325+
if (/\.[cm]?[jt]sx?$/.test(dir)) {
1326+
const lastSlash = dir.lastIndexOf("/");
1327+
if (lastSlash > 0) dir = dir.slice(0, lastSlash);
1328+
}
1329+
const vfsResult = await resolveModule(req, dir, deps.filesystem, "require", deps.resolutionCache);
1330+
if (vfsResult) return vfsResult;
1331+
// Fallback: resolve through real host paths for pnpm symlink compatibility.
1332+
const hostDir = deps.sandboxToHostPath?.(dir) ?? dir;
1333+
try {
1334+
let realDir: string;
1335+
try { realDir = realpathSync(hostDir); } catch { realDir = hostDir; }
1336+
// Try require.resolve (works for CJS packages)
1337+
try {
1338+
return hostRequire.resolve(req, { paths: [realDir] });
1339+
} catch { /* ESM-only, try manual resolution */ }
1340+
// Manual package.json resolution for ESM packages
1341+
const resolved = resolvePackageExport(req, realDir);
1342+
if (resolved) return resolved;
1343+
} catch { /* resolution failed */ }
1344+
return null;
11391345
};
11401346

1141-
// Async file read + dynamic import transform
1347+
// Dynamic import bridge — returns null to fall back to require() in the sandbox.
1348+
// V8 ESM module mode handles static imports natively via module_resolve_callback;
1349+
// this handler covers the __dynamicImport() path used in exec mode.
1350+
handlers[K.dynamicImport] = async (): Promise<null> => null;
1351+
1352+
// Async file read + dynamic import transform.
1353+
// Also serves ESM wrappers for built-in modules (fs, path, etc.) when
1354+
// used from V8's ES module system which calls _loadFile after _resolveModule.
11421355
handlers[K.loadFile] = async (path: unknown): Promise<string | null> => {
1143-
const source = await loadFile(String(path), deps.filesystem);
1356+
const p = String(path);
1357+
// Built-in module ESM wrappers (V8 module system resolves 'fs' then loads it)
1358+
const bare = p.replace(/^node:/, "");
1359+
const builtin = getStaticBuiltinWrapperSource(bare);
1360+
if (builtin) return builtin;
1361+
// Polyfill-backed builtins (crypto, zlib, etc.)
1362+
if (hasPolyfill(bare)) {
1363+
const code = await bundlePolyfill(bare);
1364+
// Wrap polyfill CJS bundle as ESM: export default + named re-exports
1365+
return `const _p = (function(){var module={exports:{}};var exports=module.exports;${code};return module.exports})();\nexport default _p;\n` +
1366+
`for(const[k,v]of Object.entries(_p)){if(k!=='default'&&/^[A-Za-z_$]/.test(k))globalThis['__esm_'+k]=v;}\n`;
1367+
}
1368+
// Regular file — keep ESM source intact for V8 module system
1369+
const source = await loadFile(p, deps.filesystem);
11441370
if (source === null) return null;
11451371
return transformDynamicImport(source);
11461372
};

packages/secure-exec-nodejs/src/execution-driver.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -295,13 +295,17 @@ export class NodeExecutionDriver implements RuntimeDriver {
295295
private memoryLimit: number;
296296
private disposed: boolean = false;
297297
private flattenedBindings: FlattenedBinding[] | null = null;
298+
// Unwrapped filesystem for path translation (toHostPath/toSandboxPath)
299+
private rawFilesystem: VirtualFileSystem | undefined;
298300

299301
constructor(options: NodeExecutionDriverOptions) {
300302
this.memoryLimit = options.memoryLimit ?? 128;
301303
const system = options.system;
302304
const permissions = system.permissions;
303-
const filesystem = system.filesystem
304-
? wrapFileSystem(system.filesystem, permissions)
305+
// Keep unwrapped filesystem for path translation (toHostPath/toSandboxPath)
306+
this.rawFilesystem = system.filesystem;
307+
const filesystem = this.rawFilesystem
308+
? wrapFileSystem(this.rawFilesystem, permissions)
305309
: createFsStub();
306310
const commandExecutor = system.commandExecutor
307311
? wrapCommandExecutor(system.commandExecutor, permissions)
@@ -459,6 +463,10 @@ export class NodeExecutionDriver implements RuntimeDriver {
459463
...buildModuleLoadingBridgeHandlers({
460464
filesystem: s.filesystem,
461465
resolutionCache: s.resolutionCache,
466+
sandboxToHostPath: (p) => {
467+
const rfs = this.rawFilesystem as any;
468+
return typeof rfs?.toHostPath === "function" ? rfs.toHostPath(p) : null;
469+
},
462470
}, {
463471
// Dispatch handlers routed through _loadPolyfill for V8 runtime compat
464472
...cryptoResult.handlers,
@@ -525,12 +533,12 @@ export class NodeExecutionDriver implements RuntimeDriver {
525533
}),
526534
...buildModuleResolutionBridgeHandlers({
527535
sandboxToHostPath: (p) => {
528-
const fs = s.filesystem as any;
529-
return typeof fs.toHostPath === "function" ? fs.toHostPath(p) : null;
536+
const rfs = this.rawFilesystem as any;
537+
return typeof rfs?.toHostPath === "function" ? rfs.toHostPath(p) : null;
530538
},
531539
hostToSandboxPath: (p) => {
532-
const fs = s.filesystem as any;
533-
return typeof fs.toSandboxPath === "function" ? fs.toSandboxPath(p) : p;
540+
const rfs = this.rawFilesystem as any;
541+
return typeof rfs?.toSandboxPath === "function" ? rfs.toSandboxPath(p) : p;
534542
},
535543
}),
536544
...buildPtyBridgeHandlers({

0 commit comments

Comments
 (0)