Skip to content

Commit 8d1b871

Browse files
committed
feat: [US-008] - [Fix bridge bootstrap so NodeRuntime works outside vitest]
1 parent ff71d2e commit 8d1b871

6 files changed

Lines changed: 119 additions & 10 deletions

File tree

packages/core/isolate-runtime/src/inject/require-setup.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,9 @@
323323
}
324324

325325
if (name === 'crypto') {
326-
var _runtimeRequire = typeof require === 'function' ? require : globalThis.require;
326+
// Avoid bare `require` here so built dist bundles don't rewrite it to
327+
// an ESM helper that throws before the sandbox installs globalThis.require.
328+
var _runtimeRequire = globalThis.require;
327329
var _streamModule = _runtimeRequire && _runtimeRequire('stream');
328330
var _utilModule = _runtimeRequire && _runtimeRequire('util');
329331
var _Transform = _streamModule && _streamModule.Transform;

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

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

packages/nodejs/src/bridge-handlers.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
HOST_BRIDGE_GLOBAL_KEYS,
4848
} from "./bridge-contract.js";
4949
import {
50+
AF_INET,
51+
SOCK_STREAM,
5052
mkdir,
5153
FDTableManager,
5254
O_RDONLY,
@@ -1881,9 +1883,6 @@ function buildKernelSocketBridgeHandlers(
18811883
socketTable: import("@secure-exec/core").SocketTable,
18821884
pid: number,
18831885
): NetSocketBridgeResult {
1884-
const {
1885-
AF_INET, SOCK_STREAM,
1886-
} = require("@secure-exec/core") as typeof import("@secure-exec/core");
18871886
const handlers: BridgeHandlers = {};
18881887
const K = HOST_BRIDGE_GLOBAL_KEYS;
18891888

@@ -3365,10 +3364,6 @@ export function buildNetworkBridgeHandlers(deps: NetworkBridgeDeps): NetworkBrid
33653364

33663365
return (async () => {
33673366
try {
3368-
const {
3369-
AF_INET, SOCK_STREAM,
3370-
} = require("@secure-exec/core") as typeof import("@secure-exec/core");
3371-
33723367
const host = normalizeLoopbackHostname(options.hostname);
33733368
debugHttpBridge("listen start", options.serverId, host, options.port ?? 0);
33743369
const listenSocketId = socketTable.create(AF_INET, SOCK_STREAM, 0, pid);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { execFile } from "node:child_process";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath, pathToFileURL } from "node:url";
4+
import { promisify } from "node:util";
5+
import { describe, expect, it } from "vitest";
6+
7+
const execFileAsync = promisify(execFile);
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const WORKSPACE_ROOT = resolve(__dirname, "../../../../..");
10+
const DIST_INDEX_URL = pathToFileURL(
11+
resolve(WORKSPACE_ROOT, "packages/secure-exec/dist/index.js"),
12+
).href;
13+
14+
async function runStandaloneScript(source: string): Promise<string> {
15+
const { stdout, stderr } = await execFileAsync(
16+
"node",
17+
["--input-type=module", "-e", source],
18+
{
19+
cwd: WORKSPACE_ROOT,
20+
timeout: 30_000,
21+
},
22+
);
23+
24+
expect(stderr).toBe("");
25+
return stdout.trim();
26+
}
27+
28+
describe("standalone dist bootstrap", () => {
29+
it("supports runtime.exec and kernel.spawn outside vitest transforms", async () => {
30+
const stdout = await runStandaloneScript(`
31+
import {
32+
NodeRuntime,
33+
createInMemoryFileSystem,
34+
createKernel,
35+
createNodeDriver,
36+
createNodeRuntime,
37+
createNodeRuntimeDriverFactory,
38+
} from ${JSON.stringify(DIST_INDEX_URL)};
39+
40+
const stdio = [];
41+
const runtime = new NodeRuntime({
42+
onStdio: (event) => {
43+
if (event.channel === "stdout") {
44+
stdio.push(event.message);
45+
}
46+
},
47+
systemDriver: createNodeDriver(),
48+
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
49+
});
50+
51+
const execResult = await runtime.exec(
52+
'console.log("hello"); const fs = require("node:fs"); console.log(typeof fs.readFileSync);',
53+
);
54+
55+
const kernel = createKernel({ filesystem: createInMemoryFileSystem() });
56+
await kernel.mount(createNodeRuntime());
57+
58+
const kernelStdout = [];
59+
const proc = kernel.spawn("node", ["-e", 'console.log(1)'], {
60+
onStdout: (chunk) => kernelStdout.push(new TextDecoder().decode(chunk)),
61+
});
62+
const kernelCode = await proc.wait();
63+
64+
await runtime.terminate();
65+
await kernel.dispose();
66+
67+
const result = JSON.stringify({
68+
execCode: execResult.code,
69+
execErrorMessage: execResult.errorMessage,
70+
stdio,
71+
kernelCode,
72+
kernelStdout: kernelStdout.join(""),
73+
});
74+
75+
await new Promise((resolve, reject) => {
76+
process.stdout.write(result, (error) => {
77+
if (error) {
78+
reject(error);
79+
return;
80+
}
81+
resolve();
82+
});
83+
});
84+
process.exit(0);
85+
`);
86+
87+
const result = JSON.parse(stdout) as {
88+
execCode: number;
89+
execErrorMessage?: string;
90+
stdio: string[];
91+
kernelCode: number;
92+
kernelStdout: string;
93+
};
94+
95+
expect(result.execCode).toBe(0);
96+
expect(result.execErrorMessage).toBeUndefined();
97+
expect(result.stdio.join("")).toContain("hello");
98+
expect(result.stdio.join("")).toContain("function");
99+
expect(result.kernelCode).toBe(0);
100+
expect(result.kernelStdout).toContain("1");
101+
}, 30_000);
102+
});

scripts/ralph/prd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
"Typecheck passes"
156156
],
157157
"priority": 8,
158-
"passes": false,
158+
"passes": true,
159159
"notes": "RELEASE BLOCKER. The bridge IIFE that injects CJS globals crashes during bootstrap when run outside vitest. Even `1+1` fails because the bootstrap itself references require. The vitest transform pipeline does something (likely TypeScript path resolution or module format handling) that makes it work. Check packages/core/src/generated/isolate-runtime.ts and how it's loaded by the runtime driver."
160160
},
161161
{

scripts/ralph/progress.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- When the session cipher bridge is unavailable, crypto constructor-time validation still has to happen eagerly through the one-shot host bridge; otherwise Node conformance misses `createCipheriv()`/`createDecipheriv()` throw-on-construction cases
77
- `bridge-initial-globals.ts` intentionally removes `SharedArrayBuffer`; when vendored conformance files hard-require it, classify the expectation as a `security-constraint` instead of treating the failure as a crypto implementation bug
88
- When a crypto/node-conformance expectation reason looks stale, temporarily remove just that entry and rerun the exact vendored file filter; the runner prints the real sandbox stderr, which is often a missing fixture or error-shape mismatch rather than the older bridge-gap guess
9+
- Host-side ESM packages in `packages/nodejs/src/` cannot use bare `require(...)`; standalone `dist/` execution runs them as real ESM, so bridge helpers must use static imports or `createRequire()`
910
- Conformance tests live in packages/secure-exec/tests/node-conformance/ — vendored Node.js v22.14.0 test/parallel/
1011
- Runner is packages/secure-exec/tests/node-conformance/runner.test.ts — run with: pnpm vitest run packages/secure-exec/tests/node-conformance/runner.test.ts
1112
- Expectations are in packages/secure-exec/tests/node-conformance/expectations.json — each entry has expected (pass/fail/skip), reason, category
@@ -94,3 +95,12 @@
9495
- The vendored runner is the quickest way to verify expectation accuracy: removing one entry and rerunning the exact file gives a concrete stderr snippet that can be copied into the reason.
9596
- Module-overlay drift still shows up in conformance as missing APIs like `require('crypto').randomUUID` and `crypto.hkdf`, even when related globals or bridge handlers exist elsewhere.
9697
---
98+
## [2026-03-25 04:26 PDT] - US-008
99+
- Implemented the standalone bootstrap fix by removing the last dist-broken bare `require` paths from both the isolate bootstrap and the host bridge handlers, so `NodeRuntime.exec()` and kernel-managed `node` processes work from the built `packages/secure-exec/dist/index.js` entrypoint.
100+
- Added a dist-based regression smoke test that spawns a real host `node --input-type=module` process, verifies `runtime.exec()` emits `hello` and supports `require("node:fs")`, and verifies `kernel.spawn("node", ["-e", "console.log(1)"])` captures stdout.
101+
- Files changed: `packages/core/isolate-runtime/src/inject/require-setup.ts`, `packages/core/src/generated/isolate-runtime.ts`, `packages/nodejs/src/bridge-handlers.ts`, `packages/secure-exec/tests/runtime-driver/node/standalone-dist-smoke.test.ts`, `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
102+
- **Learnings for future iterations:**
103+
- Standalone `dist/` verification has to exercise the built package from a real host `node` process; vitest-importing source files can hide ESM-only failures in host bridge code.
104+
- `packages/nodejs/src/bridge-handlers.ts` runs on the host as ESM, so any leftover `require("@secure-exec/core")` calls will only fail once the published-style `dist/` path is used.
105+
- When a spawned verification script only needs to report pass/fail state, explicitly flushing stdout and exiting keeps the smoke test focused on bootstrap behavior instead of shared-runtime process lifetime.
106+
---

0 commit comments

Comments
 (0)