Skip to content

Commit a09193d

Browse files
committed
chore: US-027 bridge improvements for Pi in-VM execution
Major sandbox bridge fixes enabling in-VM ESM execution of complex Node.js apps: - Fix _resolveModule to use ESM export conditions (import mode) for V8 module system - Fix polyfill double-wrapping in _loadFile (bundlePolyfill returns IIFE) - Add esbuild __export() pattern to CJS named export extraction - Fix CJS wrapper const→let for exports reassignment (ajv compat) - Add url module static wrapper with correct fileURLToPath/pathToFileURL - Add global = globalThis alias for CJS compat - Add tty, net, path (posix/win32) to BUILTIN_NAMED_EXPORTS - Fix stdin end event for non-TTY (empty stdin emits end on resume) - Add AbortSignal.addEventListener/removeEventListener no-op stubs - Augment crypto polyfill with bridge-backed randomUUID - Add stdout/stderr write callback support and writableLength - Add Response.body ReadableStream to bridge fetch - Add SSRF bypass for localhost in test network adapter - Test uses ANTHROPIC_BASE_URL + allowAll permissions for in-VM Pi
1 parent c05ed84 commit a09193d

8 files changed

Lines changed: 216 additions & 53 deletions

File tree

packages/core/src/shared/esm-utils.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ export function wrapCJSForESMWithModulePath(
7676
const __filename = ${JSON.stringify(modulePath)};
7777
const __dirname = ${JSON.stringify(moduleDir)};
7878
const require = (name) => globalThis._requireFrom(name, __dirname);
79+
const global = globalThis;
7980
const module = { exports: {} };
80-
const exports = module.exports;
81+
let exports = module.exports;
8182
${code}
8283
const __cjs = module.exports;
8384
export default __cjs;
@@ -87,9 +88,11 @@ export function wrapCJSForESMWithModulePath(
8788
}
8889

8990
/**
90-
* Scan CJS code for `module.exports.X =`, `exports.X =`, and
91-
* `Object.defineProperty(exports, 'X', ...)` patterns to discover named exports
92-
* that can be re-exported from the ESM wrapper.
91+
* Scan CJS code for named export patterns:
92+
* - `module.exports.X =`
93+
* - `exports.X =`
94+
* - `Object.defineProperty(exports, 'X', ...)`
95+
* - esbuild `__export(obj, { X: () => ... })` pattern
9396
*/
9497
function extractCjsNamedExports(code: string): string[] {
9598
const names = new Set<string>();
@@ -109,6 +112,12 @@ function extractCjsNamedExports(code: string): string[] {
109112
for (const match of code.matchAll(/\bObject\.defineProperty\(\s*(?:module\.)?exports\s*,\s*["']([^"']+)["']/g)) {
110113
add(match[1]);
111114
}
115+
// esbuild __export() pattern: __export(obj, { key: () => value, ... })
116+
for (const block of code.matchAll(/__export\(\s*\w+\s*,\s*\{([^}]+)\}/g)) {
117+
for (const entry of block[1].matchAll(/([A-Za-z_$][\w$]*)\s*:/g)) {
118+
add(entry[1]);
119+
}
120+
}
112121

113122
return Array.from(names).sort();
114123
}

packages/nodejs/src/bridge-handlers.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,9 @@ export function buildModuleLoadingBridgeHandlers(
13321332
const lastSlash = dir.lastIndexOf("/");
13331333
if (lastSlash > 0) dir = dir.slice(0, lastSlash);
13341334
}
1335-
const vfsResult = await resolveModule(req, dir, deps.filesystem, "require", deps.resolutionCache);
1335+
// Use "import" mode so ESM export conditions are preferred — this handler
1336+
// is called by V8's native module system for import statements/expressions.
1337+
const vfsResult = await resolveModule(req, dir, deps.filesystem, "import", deps.resolutionCache);
13361338
if (vfsResult) return vfsResult;
13371339
// Fallback: resolve through real host paths for pnpm symlink compatibility.
13381340
const hostDir = deps.sandboxToHostPath?.(dir) ?? dir;
@@ -1367,13 +1369,20 @@ export function buildModuleLoadingBridgeHandlers(
13671369
const builtin = getStaticBuiltinWrapperSource(bare);
13681370
if (builtin) return builtin;
13691371
// Polyfill-backed builtins (crypto, zlib, etc.)
1372+
// bundlePolyfill returns an IIFE that evaluates to module.exports — use directly
13701373
if (hasPolyfill(bare)) {
13711374
const code = await bundlePolyfill(bare);
13721375
const namedExports = BUILTIN_NAMED_EXPORTS[bare] ?? [];
13731376
const namedLines = namedExports
13741377
.map(name => `export const ${name} = _p.${name};`)
13751378
.join("\n");
1376-
return `const _p = (function(){var module={exports:{}};var exports=module.exports;${code};return module.exports})();\nexport default _p;\n${namedLines}\n`;
1379+
// Augment crypto polyfill with bridge-backed functions missing from browserify
1380+
const augment = bare === "crypto"
1381+
? "if(typeof _cryptoRandomUUID!=='undefined'&&!_p.randomUUID){_p.randomUUID=function(){return _cryptoRandomUUID.applySync(undefined,[]);};};\n" +
1382+
"if(typeof _cryptoRandomFill!=='undefined'&&!_p.randomFillSync){_p.randomFillSync=function(b){var a=new Uint8Array(b.buffer||b,b.byteOffset||0,b.byteLength||b.length);var d=_cryptoRandomFill.applySync(undefined,[a.length]);for(var i=0;i<a.length;i++)a[i]=d.charCodeAt(i);return b;};};\n" +
1383+
"if(typeof _cryptoRandomFill!=='undefined'&&!_p.randomBytes){_p.randomBytes=function(n){var b=new Uint8Array(n);var d=_cryptoRandomFill.applySync(undefined,[n]);for(var i=0;i<n;i++)b[i]=d.charCodeAt(i);return b;};};\n"
1384+
: "";
1385+
return `const _p = ${code};\n${augment}export default _p;\n${namedLines}\n`;
13771386
}
13781387
// Recognized builtin without a static wrapper or polyfill — return empty stub with named exports
13791388
if (normalizeBuiltinSpecifier(bare)) {

packages/nodejs/src/bridge/network.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ interface FetchOptions {
9393
integrity?: string;
9494
}
9595

96+
interface FetchResponseBody {
97+
getReader(): { read(): Promise<{ value: Uint8Array | undefined; done: boolean }>; releaseLock(): void };
98+
locked: boolean;
99+
cancel(): Promise<void>;
100+
pipeTo(): Promise<void>;
101+
pipeThrough<T>(transform: { readable: T }): T;
102+
tee(): [FetchResponseBody, FetchResponseBody];
103+
}
104+
96105
interface FetchResponse {
97106
ok: boolean;
98107
status: number;
@@ -101,6 +110,7 @@ interface FetchResponse {
101110
url: string;
102111
redirected: boolean;
103112
type: string;
113+
body: FetchResponseBody;
104114
text(): Promise<string>;
105115
json(): Promise<unknown>;
106116
arrayBuffer(): Promise<ArrayBuffer>;
@@ -148,6 +158,32 @@ export async function fetch(input: string | URL | Request, options: FetchOptions
148158
body?: string;
149159
};
150160

161+
// Build a ReadableStream-like body from the complete response text
162+
const bodyText = response.body || "";
163+
const bodyBytes = new TextEncoder().encode(bodyText);
164+
let bodyRead = false;
165+
166+
// Minimal ReadableStream that yields the complete response in one chunk
167+
const body: FetchResponseBody = {
168+
getReader() {
169+
let readerDone = bodyRead;
170+
return {
171+
async read() {
172+
if (readerDone) return { value: undefined as Uint8Array | undefined, done: true };
173+
readerDone = true;
174+
bodyRead = true;
175+
return { value: bodyBytes, done: false };
176+
},
177+
releaseLock() {},
178+
};
179+
},
180+
locked: false,
181+
cancel() { return Promise.resolve(); },
182+
pipeTo() { return Promise.resolve(); },
183+
pipeThrough<T>(transform: { readable: T }): T { return transform.readable; },
184+
tee(): [FetchResponseBody, FetchResponseBody] { return [body, body]; },
185+
};
186+
151187
// Create Response-like object
152188
return {
153189
ok: response.ok,
@@ -157,16 +193,16 @@ export async function fetch(input: string | URL | Request, options: FetchOptions
157193
url: response.url || resolvedUrl,
158194
redirected: response.redirected || false,
159195
type: "basic",
196+
body,
160197

161198
async text(): Promise<string> {
162-
return response.body || "";
199+
return bodyText;
163200
},
164201
async json(): Promise<unknown> {
165-
return JSON.parse(response.body || "{}");
202+
return JSON.parse(bodyText || "{}");
166203
},
167204
async arrayBuffer(): Promise<ArrayBuffer> {
168-
// Not fully supported - return empty buffer
169-
return new ArrayBuffer(0);
205+
return bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength);
170206
},
171207
async blob(): Promise<never> {
172208
throw new Error("Blob not supported in sandbox");

packages/nodejs/src/bridge/process.ts

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,13 @@ function _emit(event: string, ...args: unknown[]): boolean {
288288

289289
// Stdio stream shape shared by stdout and stderr
290290
interface StdioWriteStream {
291-
write(data: unknown): boolean;
291+
write(data: unknown, ...rest: unknown[]): boolean;
292292
end(): StdioWriteStream;
293293
on(): StdioWriteStream;
294294
once(): StdioWriteStream;
295295
emit(): boolean;
296296
writable: boolean;
297+
writableLength: number;
297298
isTTY: boolean;
298299
columns: number;
299300
rows: number;
@@ -314,10 +315,13 @@ function _getStderrIsTTY(): boolean {
314315

315316
// Stdout stream
316317
const _stdout: StdioWriteStream = {
317-
write(data: unknown): boolean {
318-
if (typeof _log !== "undefined") {
318+
write(data: unknown, ...rest: unknown[]): boolean {
319+
if (typeof _log !== "undefined" && data !== "" && data != null) {
319320
_log.applySync(undefined, [String(data).replace(/\n$/, "")]);
320321
}
322+
// Support write(data, callback) and write(data, encoding, callback)
323+
const cb = typeof rest[rest.length - 1] === "function" ? rest[rest.length - 1] as () => void : null;
324+
if (cb) cb();
321325
return true;
322326
},
323327
end(): StdioWriteStream {
@@ -333,17 +337,21 @@ const _stdout: StdioWriteStream = {
333337
return false;
334338
},
335339
writable: true,
340+
writableLength: 0,
336341
get isTTY(): boolean { return _getStdoutIsTTY(); },
337342
columns: 80,
338343
rows: 24,
339344
};
340345

341346
// Stderr stream
342347
const _stderr: StdioWriteStream = {
343-
write(data: unknown): boolean {
344-
if (typeof _error !== "undefined") {
348+
write(data: unknown, ...rest: unknown[]): boolean {
349+
if (typeof _error !== "undefined" && data !== "" && data != null) {
345350
_error.applySync(undefined, [String(data).replace(/\n$/, "")]);
346351
}
352+
// Support write(data, callback) and write(data, encoding, callback)
353+
const cb = typeof rest[rest.length - 1] === "function" ? rest[rest.length - 1] as () => void : null;
354+
if (cb) cb();
347355
return true;
348356
},
349357
end(): StdioWriteStream {
@@ -359,6 +367,7 @@ const _stderr: StdioWriteStream = {
359367
return false;
360368
},
361369
writable: true,
370+
writableLength: 0,
362371
get isTTY(): boolean { return _getStderrIsTTY(); },
363372
columns: 80,
364373
rows: 24,
@@ -393,33 +402,37 @@ function getStdinFlowMode(): boolean { return (globalThis as Record<string, unkn
393402
function setStdinFlowMode(v: boolean): void { (globalThis as Record<string, unknown>)._stdinFlowMode = v; }
394403

395404
function _emitStdinData(): void {
396-
if (getStdinEnded() || !getStdinData()) return;
397-
398-
// In flowing mode, emit all remaining data
399-
if (getStdinFlowMode() && getStdinPosition() < getStdinData().length) {
400-
const chunk = getStdinData().slice(getStdinPosition());
401-
setStdinPosition(getStdinData().length);
402-
403-
// Emit data event
404-
const dataListeners = [...(_stdinListeners["data"] || []), ...(_stdinOnceListeners["data"] || [])];
405-
_stdinOnceListeners["data"] = [];
406-
for (const listener of dataListeners) {
407-
listener(chunk);
408-
}
409-
410-
// Emit end after all data
411-
setStdinEnded(true);
412-
const endListeners = [...(_stdinListeners["end"] || []), ...(_stdinOnceListeners["end"] || [])];
413-
_stdinOnceListeners["end"] = [];
414-
for (const listener of endListeners) {
415-
listener();
405+
if (getStdinEnded()) return;
406+
407+
// In flowing mode, emit remaining data then end
408+
if (getStdinFlowMode()) {
409+
const data = getStdinData();
410+
if (data && getStdinPosition() < data.length) {
411+
const chunk = data.slice(getStdinPosition());
412+
setStdinPosition(data.length);
413+
414+
// Emit data event
415+
const dataListeners = [...(_stdinListeners["data"] || []), ...(_stdinOnceListeners["data"] || [])];
416+
_stdinOnceListeners["data"] = [];
417+
for (const listener of dataListeners) {
418+
listener(chunk);
419+
}
416420
}
417421

418-
// Emit close
419-
const closeListeners = [...(_stdinListeners["close"] || []), ...(_stdinOnceListeners["close"] || [])];
420-
_stdinOnceListeners["close"] = [];
421-
for (const listener of closeListeners) {
422-
listener();
422+
// Non-TTY stdin: emit end after all data (or immediately if empty).
423+
// TTY stdin uses the streaming _stdinRead read loop for end detection.
424+
if (!_getStdinIsTTY()) {
425+
setStdinEnded(true);
426+
const endListeners = [...(_stdinListeners["end"] || []), ...(_stdinOnceListeners["end"] || [])];
427+
_stdinOnceListeners["end"] = [];
428+
for (const listener of endListeners) {
429+
listener();
430+
}
431+
const closeListeners = [...(_stdinListeners["close"] || []), ...(_stdinOnceListeners["close"] || [])];
432+
_stdinOnceListeners["close"] = [];
433+
for (const listener of closeListeners) {
434+
listener();
435+
}
423436
}
424437
}
425438
}

packages/nodejs/src/builtin-modules.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,11 @@ export const BUILTIN_NAMED_EXPORTS: Record<string, string[]> = {
336336
"join",
337337
"normalize",
338338
"parse",
339+
"posix",
339340
"relative",
340341
"resolve",
342+
"toNamespacedPath",
343+
"win32",
341344
],
342345
async_hooks: [
343346
"AsyncLocalStorage",
@@ -515,6 +518,21 @@ export const BUILTIN_NAMED_EXPORTS: Record<string, string[]> = {
515518
"stringify",
516519
"unescape",
517520
],
521+
tty: [
522+
"isatty",
523+
"ReadStream",
524+
"WriteStream",
525+
],
526+
net: [
527+
"Socket",
528+
"Server",
529+
"createServer",
530+
"createConnection",
531+
"connect",
532+
"isIP",
533+
"isIPv4",
534+
"isIPv6",
535+
],
518536
"stream/web": [
519537
"ReadableStream",
520538
"ReadableStreamDefaultReader",

packages/nodejs/src/esm-compiler.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,29 @@ const STATIC_BUILTIN_WRAPPER_SOURCES: Readonly<Record<string, string>> = {
9090
"return{pipeline:promisePipeline,finished:promiseFinished}})()",
9191
BUILTIN_NAMED_EXPORTS["stream/promises"],
9292
),
93+
url: (() => {
94+
// Custom url wrapper with Node.js-compatible fileURLToPath/pathToFileURL.
95+
// The node-stdlib-browser url polyfill's fileURLToPath rejects valid file:// URLs,
96+
// so we provide correct implementations alongside the standard URL/URLSearchParams.
97+
const binding = "(function(){" +
98+
"var u=globalThis.URL?{URL:globalThis.URL,URLSearchParams:globalThis.URLSearchParams}:{};" +
99+
"u.fileURLToPath=function(input){" +
100+
"var s=typeof input==='string'?input:input&&input.href||String(input);" +
101+
"if(s.startsWith('file:///'))return decodeURIComponent(s.slice(7));" +
102+
"if(s.startsWith('file://'))return decodeURIComponent(s.slice(7));" +
103+
"if(s.startsWith('/'))return s;" +
104+
"throw new TypeError('The URL must be of scheme file');};" +
105+
"u.pathToFileURL=function(p){return new URL('file://'+encodeURI(p));};" +
106+
"u.format=function(u,o){if(typeof u==='string')return u;if(u instanceof URL)return u.toString();return '';};" +
107+
"u.parse=function(s){try{var p=new URL(s);return{protocol:p.protocol,hostname:p.hostname,port:p.port,pathname:p.pathname,search:p.search,hash:p.hash,href:p.href};}catch{return null;}};" +
108+
"u.resolve=function(from,to){return new URL(to,from).toString();};" +
109+
"u.domainToASCII=function(d){return d;};" +
110+
"u.domainToUnicode=function(d){return d;};" +
111+
"u.Url=function(){};" +
112+
"u.resolveObject=function(){return{};};" +
113+
"return u;})()";
114+
return buildWrapperSource(binding, BUILTIN_NAMED_EXPORTS.url);
115+
})(),
93116
v8: buildWrapperSource("globalThis._moduleCache?.v8 || {}", []),
94117
};
95118

packages/nodejs/src/execution-driver.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,19 @@ function buildPostRestoreScript(
768768
parts.push(getIsolateRuntimeSource("setupFsFacade"));
769769
parts.push(getIsolateRuntimeSource("setupDynamicImport"));
770770

771+
// Node.js CJS compat: `global` is an alias for `globalThis`
772+
parts.push(`if(typeof global==='undefined')globalThis.global=globalThis;`);
773+
774+
// AbortSignal EventTarget compat: V8's AbortSignal may lack addEventListener/removeEventListener.
775+
// Provide no-op stubs so callers don't throw, but don't create persistent listener
776+
// references that would prevent the V8 session from exiting.
777+
parts.push(`(function(){` +
778+
`if(typeof AbortSignal!=='undefined'&&!AbortSignal.prototype.addEventListener){` +
779+
`AbortSignal.prototype.addEventListener=function(){};` +
780+
`AbortSignal.prototype.removeEventListener=function(){};` +
781+
`AbortSignal.prototype.dispatchEvent=function(){return true;};` +
782+
`}})();`);
783+
771784
// Inject bridge setup config
772785
parts.push(`globalThis.__runtimeBridgeSetupConfig = ${JSON.stringify({
773786
initialCwd: bridgeConfig.initialCwd,

0 commit comments

Comments
 (0)