Skip to content

Commit 86353c9

Browse files
authored
feat: improve Node.js conformance and runtime parity (#54)
1 parent 5f0b594 commit 86353c9

116 files changed

Lines changed: 20682 additions & 4331 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agent/contracts/node-bridge.md

Lines changed: 209 additions & 0 deletions
Large diffs are not rendered by default.

.agent/contracts/node-stdlib.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,33 @@ Modules classified as Unsupported (Tier 5) SHALL throw immediately when required
9696
- **THEN** the call MUST throw an error indicating the module is not supported in sandbox
9797

9898
### Requirement: fs Missing API Classification
99-
The following `fs` APIs SHALL be classified as Deferred with deterministic error behavior: `watch`, `watchFile`. The APIs `chmod`, `chown`, `link`, `symlink`, `readlink`, `truncate`, `utimes`, `access`, and `realpath` SHALL be documented as implemented (Bridge tier), delegating to the VFS with permission checks.
99+
The following `fs` watcher APIs SHALL be classified as Deferred with deterministic error behavior: `watch`, `watchFile`, and `fs/promises.watch`. The sandbox VFS/kernel has no inotify/kqueue/FSEvents-equivalent primitive, so these APIs MUST fail fast instead of hanging while waiting for events that can never arrive. The APIs `chmod`, `chown`, `link`, `symlink`, `readlink`, `truncate`, `utimes`, `access`, and `realpath` SHALL be documented as implemented (Bridge tier), delegating to the VFS with permission checks.
100100

101101
#### Scenario: Calling a deferred fs API
102102
- **WHEN** sandboxed code calls `fs.watch()`
103103
- **THEN** the call MUST throw `"fs.watch is not supported in sandbox — use polling"`
104104

105+
#### Scenario: Calling deferred watcher APIs through fs/promises
106+
- **WHEN** sandboxed code iterates `require("fs/promises").watch(...)`
107+
- **THEN** the iterator MUST reject with `"fs.promises.watch is not supported in sandbox — use polling"`
108+
- **AND** it MUST preserve Node-compatible `ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, and `AbortError` validation behavior before the deferred unsupported error path
109+
105110
#### Scenario: Calling an implemented fs API previously listed as missing
106111
- **WHEN** sandboxed code calls `fs.access("/some/path", callback)`
107112
- **THEN** the call MUST execute normally via the fs bridge without error
108113

114+
### Requirement: fs Validation Paths Preserve Node ERR_* Shapes
115+
Bridge-provided `fs` APIs SHALL throw Node-compatible validation errors before asynchronous dispatch when the argument contract is violated.
116+
117+
#### Scenario: Callback-style fs API is missing or given a non-function callback
118+
- **WHEN** sandboxed code calls callback-style APIs such as `fs.open()`, `fs.close()`, `fs.exists()`, `fs.stat()`, or `fs.mkdtemp()` without a valid callback
119+
- **THEN** the bridge MUST throw `ERR_INVALID_ARG_TYPE` synchronously instead of returning a Promise or reporting the validation failure through the callback
120+
121+
#### Scenario: fs validation rejects invalid encodings and numeric option types
122+
- **WHEN** sandboxed code passes an invalid encoding to `fs.readFile*()`, `fs.readdir*()`, `fs.readlink*()`, `fs.writeFile*()`, `fs.appendFile*()`, `fs.realpath*()`, `fs.mkdtemp*()`, `fs.ReadStream()`, `fs.WriteStream()`, or `fs.watch()`
123+
- **THEN** the bridge MUST throw `ERR_INVALID_ARG_VALUE`
124+
- **AND** invalid numeric `start` / `end` stream options or fd/path argument types MUST throw `ERR_INVALID_ARG_TYPE` or `ERR_OUT_OF_RANGE` with Node-compatible names
125+
109126
### Requirement: child_process.fork Is Permanently Unsupported
110127
`child_process.fork()` SHALL be classified as Unsupported and MUST throw a deterministic error explaining that IPC across the isolate boundary is not supported.
111128

CLAUDE.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
### Node.js Conformance Test Integrity
3333

3434
- conformance tests live in `packages/secure-exec/tests/node-conformance/` — they are vendored upstream Node.js v22.14.0 test/parallel/ tests run through the sandbox
35+
- vendored Node conformance helper shims live in `packages/secure-exec/tests/node-conformance/common/`; if a WPT-derived vendored test fails on a missing `../common/*` helper, add the minimal harness/shim there instead of rewriting the vendored test file
3536
- `docs-internal/nodejs-compat-roadmap.md` tracks every non-passing test with its fix category and resolution
3637
- when implementing bridge/polyfill features where both sides go through our code (e.g., loopback HTTP server + client), prevent overfitting:
3738
- **wire-level snapshot tests**: capture raw protocol bytes and compare against known-good captures from real Node.js
@@ -42,9 +43,13 @@
4243
- **host-side assertion verification**: periodically run assert-heavy conformance tests through host Node.js to verify the assert polyfill isn't masking failures
4344
- never inflate conformance numbers — if a test self-skips (exits 0 without testing anything), mark it `vacuous-skip` in expectations.json, not as a real pass
4445
- every entry in `expectations.json` must have a specific, verifiable reason — no vague "fails in sandbox" reasons
46+
- when rerunning a single expected-fail conformance file through `runner.test.ts`, a green Vitest result only means the expectation still matches; only the explicit `now passes! Remove its expectation` failure proves the vendored test itself now passes and the entry is stale
47+
- before deleting explicit `pass` overrides behind a negated glob, rerun the exact promoted vendored files through a direct `createTestNodeRuntime()` harness or another no-expectation path; broad module cleanup can still hide stale passes
4548
- after changing expectations.json or adding/removing test files, regenerate both the JSON report and docs page: `pnpm tsx scripts/generate-node-conformance-report.ts`
4649
- the script produces `packages/secure-exec/tests/node-conformance/conformance-report.json` (machine-readable) and `docs/nodejs-conformance-report.mdx` (docs page) — commit both
4750
- to run the actual conformance suite: `pnpm vitest run packages/secure-exec/tests/node-conformance/runner.test.ts`
51+
- raw `net.connect()` traffic to sandbox `http.createServer()` is implemented entirely in `packages/nodejs/src/bridge/network.ts`; when fixing loopback HTTP behavior, re-run the vendored pipeline/transfer files (`test-http-get-pipeline-problem.js`, `test-http-pipeline-requests-connection-leak.js`, `test-http-transfer-encoding-*.js`, `test-http-chunked-304.js`) because they all exercise the same parser/serializer path
52+
- For callback-style `fs` bridge methods, do Node-style argument validation before entering the callback/error-delivery wrapper; otherwise invalid args that should throw synchronously get converted into callback errors or Promise returns and vendored fs validation coverage goes red
4853

4954
## Tooling
5055

@@ -111,6 +116,18 @@
111116

112117
- read `docs-internal/arch/overview.md` for the component map (NodeRuntime, RuntimeDriver, NodeDriver, NodeExecutionDriver, ModuleAccessFileSystem, Permissions)
113118
- keep it up to date when adding, removing, or significantly changing components
119+
- keep host bootstrap polyfills in `packages/nodejs/src/execution-driver.ts` aligned with isolate bootstrap polyfills in `packages/core/isolate-runtime/src/inject/require-setup.ts`; drift in shared globals like `AbortController` causes sandbox-only behavior gaps that source-level tests can miss
120+
- vendored fs abort tests deep-freeze option bags via `common.mustNotMutateObjectDeep()`, so sandbox `AbortSignal` state must live outside writable instance properties; freezing `{ signal }` must not break later `controller.abort()`
121+
- vendored `common.mustNotMutateObjectDeep()` helpers must skip populated typed-array/DataView instances; `Object.freeze(new Uint8Array([1]))` throws before the runtime under test executes, which turns option-bag immutability coverage into a harness failure
122+
- when adding bridge globals that the sandbox calls with `.apply(..., { result: { promise: true } })`, register them in the native V8 async bridge list in `native/v8-runtime/src/session.rs`; otherwise the `_loadPolyfill` shim can turn a supposed async wait into a synchronous deadlock
123+
- bridged `net.Server.listen()` must make `server.address()` readable immediately after `listen()` returns, even before the `'listening'` callback, because vendored Node tests read ephemeral ports synchronously
124+
- bridged Unix path sockets (`server.listen(path)`, `net.connect(path)`) must route through kernel `AF_UNIX`, not TCP validation, and `readableAll` / `writableAll` listener options must update the VFS socket-file mode bits that `fs.statSync()` observes
125+
- bridged `net.Socket.setTimeout()` must match Node validation codes (`ERR_INVALID_ARG_TYPE`, `ERR_OUT_OF_RANGE`) and any timeout timer created for an unrefed socket must also be unrefed so it cannot keep the runtime alive by itself
126+
- bridged `dgram.Socket` loopback semantics depend on both layers: the isolate bridge must implicitly bind unbound sender sockets before `send()`, and the kernel UDP path must rewrite wildcard local addresses (`0.0.0.0` / `::`) to concrete loopback source addresses so `rinfo.address` matches Node on self-send/echo tests
127+
- bridged `dgram.Socket` buffer-size options must be cached until `bind()` completes; Node expects unbound `get*BufferSize()` / `set*BufferSize()` calls to throw `ERR_SOCKET_BUFFER_SIZE` with `EBADF`, so eager pre-bind application hides the real error path
128+
- bridged `http2` server streams must start paused on the host and only resume when sandbox code opts into flow (`req.on('data')`, `req.resume()`, or `stream.resume()`); otherwise the host consumes DATA frames too early, sends WINDOW_UPDATE unexpectedly, and hides paused flow-control / pipeline regressions
129+
- bridge exports that userland constructs with `new` must be assigned as constructable function properties, not object-literal method shorthands; shorthand methods like `createReadStream() {}` are not constructable and vendored fs coverage calls `new fs.createReadStream(...)`
130+
- `/proc/sys/kernel/hostname` conformance hits both kernel-backed and standalone NodeRuntime paths; a procfs fix that only lands in the kernel layer still leaves `createTestNodeRuntime()` fs/FileHandle coverage red
114131

115132
## Virtual Kernel Architecture
116133

@@ -201,9 +218,6 @@ Follow the style in `packages/secure-exec/src/index.ts`.
201218

202219
- all public-facing docs (quickstart, guides, API reference, landing page, README) must focus on the **Node.js runtime** as the primary and default experience — do not lead with WasmVM, kernel internals, or multi-runtime concepts
203220
- code examples in docs should use the `NodeRuntime` API (`runtime.run()`, `runtime.exec()`) as the default path; the kernel API (`createKernel`, `kernel.spawn()`) is for advanced multi-process use cases and should be presented as secondary
204-
- keep documentation pages and their runnable example sources in sync: `docs/quickstart.mdx` must match `examples/kitchen-sink/src/`, and `docs/features/*.mdx` must match `examples/features/src/`
205-
- when updating a doc snippet, update the corresponding example file and the docs/example verification scripts in the same change
206-
- when converting runnable example code into documentation snippets, use public package imports like `from "secure-exec"` and `from "@secure-exec/typescript"` instead of repo-local source paths
207221
- WasmVM and Python docs are experimental docs and must stay grouped under the `Experimental` section in `docs/docs.json`
208222
- docs pages that must stay current with API changes:
209223
- `docs/quickstart.mdx` — update when core setup flow changes

docs/api-reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ createTypeScriptTools(options: TypeScriptToolsOptions)
136136
| `runtimeDriverFactory` | `NodeRuntimeDriverFactory` | Creates the compiler sandbox runtime. |
137137
| `memoryLimit` | `number` | Compiler sandbox isolate memory cap in MB. Default `512`. |
138138
| `cpuTimeLimitMs` | `number` | Compiler sandbox CPU time budget in ms. |
139-
| `compilerSpecifier` | `string` | Module specifier used to load the TypeScript compiler. Default `"typescript"`. |
139+
| `compilerSpecifier` | `string` | Module specifier used to load the TypeScript compiler. Default `"/root/node_modules/typescript/lib/typescript.js"`. |
140140

141141
**Methods**
142142

docs/docs.json

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,20 @@
6060
"features/typescript",
6161
"features/permissions",
6262
"features/filesystem",
63+
"features/virtual-filesystem",
6364
"features/networking",
6465
"features/module-loading",
6566
"features/output-capture",
6667
"features/resource-limits",
6768
"features/child-processes",
68-
"features/virtual-filesystem",
69-
{
70-
"group": "Advanced",
71-
"pages": [
72-
"features/process-isolation"
73-
]
74-
}
69+
"process-isolation"
7570
]
7671
},
7772
{
7873
"group": "Reference",
7974
"pages": [
8075
"api-reference",
76+
"nodejs-compatibility",
8177
"benchmarks",
8278
{
8379
"group": "Comparison",
@@ -89,10 +85,6 @@
8985
{
9086
"group": "Advanced",
9187
"pages": [
92-
"nodejs-compatibility",
93-
"nodejs-conformance-report",
94-
"posix-compatibility",
95-
"posix-conformance-report",
9688
"cost-evaluation",
9789
"architecture",
9890
"security-model"

docs/features/child-processes.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,14 @@ Sandboxed code can spawn child processes through the `CommandExecutor` interface
1212

1313
## Runnable example
1414

15-
Source file: `examples/features/src/child-processes.ts`
16-
1715
```ts
1816
import {
1917
NodeRuntime,
2018
allowAllChildProcess,
2119
createNodeDriver,
2220
createNodeRuntimeDriverFactory,
23-
} from "secure-exec";
24-
import type { CommandExecutor } from "secure-exec";
21+
} from "../../../packages/secure-exec/src/index.ts";
22+
import type { CommandExecutor } from "../../../packages/secure-exec/src/types.ts";
2523
import { spawn } from "node:child_process";
2624

2725
const commandExecutor: CommandExecutor = {
@@ -100,6 +98,8 @@ try {
10098
}
10199
```
102100

101+
Source: [examples/features/src/child-processes.ts](https://github.com/rivet-dev/secure-exec/blob/main/examples/features/src/child-processes.ts)
102+
103103
## Permission gating
104104

105105
Restrict which commands sandboxed code can spawn:

docs/features/filesystem.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,14 @@ secure-exec supports three filesystem backends. The system driver controls which
1212

1313
## Runnable example
1414

15-
Source file: `examples/features/src/filesystem.ts`
16-
1715
```ts
1816
import {
1917
NodeRuntime,
2018
allowAllFs,
2119
createInMemoryFileSystem,
2220
createNodeDriver,
2321
createNodeRuntimeDriverFactory,
24-
} from "secure-exec";
22+
} from "../../../packages/secure-exec/src/index.ts";
2523

2624
const filesystem = createInMemoryFileSystem();
2725
const runtime = new NodeRuntime({
@@ -57,6 +55,8 @@ try {
5755
}
5856
```
5957

58+
Source: [examples/features/src/filesystem.ts](https://github.com/rivet-dev/secure-exec/blob/main/examples/features/src/filesystem.ts)
59+
6060
## OPFS (browser)
6161

6262
Persistent filesystem using the Origin Private File System API. This is the default for `createBrowserDriver()`.

docs/features/module-loading.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ Sandboxed code can `require()` and `import` modules through secure-exec's module
1313

1414
## Runnable example
1515

16-
Source file: `examples/features/src/module-loading.ts`
17-
1816
```ts
1917
import path from "node:path";
2018
import { fileURLToPath } from "node:url";
@@ -23,7 +21,7 @@ import {
2321
allowAllFs,
2422
createNodeDriver,
2523
createNodeRuntimeDriverFactory,
26-
} from "secure-exec";
24+
} from "../../../packages/secure-exec/src/index.ts";
2725

2826
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
2927

@@ -60,6 +58,8 @@ try {
6058
}
6159
```
6260

61+
Source: [examples/features/src/module-loading.ts](https://github.com/rivet-dev/secure-exec/blob/main/examples/features/src/module-loading.ts)
62+
6363
## node_modules overlay
6464

6565
Node runtime executions expose a read-only dependency overlay at `/app/node_modules`, sourced from `<cwd>/node_modules` on the host (default `cwd` is `process.cwd()`).

docs/features/networking.mdx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ Network access is deny-by-default. Enable it by setting `useDefaultNetwork: true
1212

1313
## Runnable example
1414

15-
Source file: `examples/features/src/networking.ts`
16-
1715
```ts
1816
import * as http from "node:http";
1917
import {
@@ -22,7 +20,7 @@ import {
2220
createDefaultNetworkAdapter,
2321
createNodeDriver,
2422
createNodeRuntimeDriverFactory,
25-
} from "secure-exec";
23+
} from "../../../packages/secure-exec/src/index.ts";
2624

2725
const logs: string[] = [];
2826
const server = http.createServer((_req, res) => {
@@ -53,19 +51,23 @@ const runtime = new NodeRuntime({
5351
try {
5452
const result = await runtime.exec(
5553
`
56-
const response = await fetch("http://127.0.0.1:${address.port}/");
57-
const body = await response.text();
58-
59-
if (!response.ok || response.status !== 200 || body !== "network-ok") {
60-
throw new Error(
61-
"unexpected response: " + response.status + " " + body,
62-
);
63-
}
64-
65-
console.log(JSON.stringify({ status: response.status, body }));
54+
(async () => {
55+
const response = await fetch("http://127.0.0.1:${address.port}/");
56+
const body = await response.text();
57+
58+
if (!response.ok || response.status !== 200 || body !== "network-ok") {
59+
throw new Error(
60+
"unexpected response: " + response.status + " " + body,
61+
);
62+
}
63+
64+
console.log(JSON.stringify({ status: response.status, body }));
65+
})().catch((error) => {
66+
console.error(error instanceof Error ? error.message : String(error));
67+
process.exitCode = 1;
68+
});
6669
`,
6770
{
68-
filePath: "/entry.mjs",
6971
onStdio: (event) => {
7072
logs.push(`[${event.channel}] ${event.message}`);
7173
},
@@ -105,6 +107,8 @@ try {
105107
}
106108
```
107109

110+
Source: [examples/features/src/networking.ts](https://github.com/rivet-dev/secure-exec/blob/main/examples/features/src/networking.ts)
111+
108112
## Quick setup
109113

110114
<Tabs>

docs/features/output-capture.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@ Console output from sandboxed code is **not buffered** into result fields. `exec
1212

1313
## Runnable example
1414

15-
Source file: `examples/features/src/output-capture.ts`
16-
1715
```ts
1816
import {
1917
NodeRuntime,
2018
createNodeDriver,
2119
createNodeRuntimeDriverFactory,
22-
} from "secure-exec";
20+
} from "../../../packages/secure-exec/src/index.ts";
2321

2422
const events: string[] = [];
2523

@@ -66,6 +64,8 @@ try {
6664
}
6765
```
6866

67+
Source: [examples/features/src/output-capture.ts](https://github.com/rivet-dev/secure-exec/blob/main/examples/features/src/output-capture.ts)
68+
6969
## Default hook
7070

7171
Set a runtime-level hook that applies to all executions:

0 commit comments

Comments
 (0)