Skip to content

Commit 554f115

Browse files
committed
feat: CLI tool sandbox tests, e2e Docker fixtures, Node.js test suite spec
- E2E Docker fixtures: pg (connect, pool, types, errors, prepared, SSL), mysql2, ioredis, ssh2 (connect, key-auth, tunnel, SFTP dirs/large/errors) - CLI tool tests: Pi (SDK, headless, interactive, tool-use, multi-turn), Claude Code (SDK, headless, interactive, tool-use), OpenCode (headless, interactive) - Agentic workflow tests: npm install, npx exec, dev server lifecycle - Net/TLS socket bridge, crypto.subtle deriveBits/deriveKey (SCRAM-SHA-256) - VFS examples: S3 (MinIO) and SQLite filesystem drivers - Node.js test suite integration spec and backlog expansion - Ralph agent test verification and code quality rules
1 parent fa73599 commit 554f115

Some content is hidden

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

58 files changed

+8527
-6794
lines changed

docs-internal/specs/cli-tool-e2e.md

Lines changed: 396 additions & 413 deletions
Large diffs are not rendered by default.

docs-internal/specs/nodejs-test-suite.md

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

docs-internal/todo.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,37 @@ Priority order is:
5454

5555
## Priority 1: Compatibility and API Coverage
5656

57+
- [ ] Add Node.js test suite and get it passing.
58+
- Spec: `docs-internal/specs/nodejs-test-suite.md`
59+
- Run a curated subset of the official Node.js `test/parallel/` tests against secure-exec to systematically find compatibility gaps.
60+
- Vendor tests, provide a `common` shim (mustCall, mustSucceed, tmpdir, fixtures), run each through `proc.exec()` in a fresh `NodeRuntime`, report per-module pass/fail/skip/error.
61+
- Ratchet rule: once a test passes, it cannot regress without justification.
62+
- **Phase 1 — Harness + path module:**
63+
- [ ] Build `common` shim module (mustCall, mustSucceed, mustNotCall, expectsError, tmpdir, fixtures, platform checks) as injectable CJS string for sandbox require() interception.
64+
- [ ] Build test runner engine (`runner.ts`) + Vitest driver (`nodejs-suite.test.ts`) + manifest format (`manifest.json`). Runner creates fresh NodeRuntime per test, prepends common shim, captures exit code/stdio. Driver reads manifest, generates one Vitest test per entry, enforces ratchet.
65+
- [ ] Vendor `test-path-*.js` from Node.js v22.14.0. Validate harness works. Target 100% pass rate (path is a pure polyfill via path-browserify, ~15 test files).
66+
- **Phase 2 — Pure-JS polyfill modules:**
67+
- [ ] Vendor + run `buffer` tests (~60 files). Expected 80-95% pass rate.
68+
- [ ] Vendor + run `events` tests (~30 files). Expected 80-95% pass rate.
69+
- [ ] Vendor + run `url` + `querystring` + `string_decoder` tests (~35 files combined).
70+
- [ ] Vendor + run `util` + `assert` tests (~60 files combined). Expect util.inspect() divergences.
71+
- **Phase 3 — Bridge modules:**
72+
- [ ] Vendor + run `fs` tests (~150 files, largest surface). Skip deferred APIs (chmod, chown, symlink, watch). Target 50%+ on compatible tests.
73+
- [ ] Vendor + run `process` + `os` + `timers` tests. Skip exit/abort/signal tests for process.
74+
- [ ] Vendor + run `child_process` tests (~50 files). Skip fork (not bridged). Target spawn/exec basics.
75+
- [ ] Vendor + run `http` + `dns` tests. Skip Agent pooling, upgrade, trailers for http.
76+
- **Phase 4 — Stubs + automation + dashboard:**
77+
- [ ] Vendor + run `stream` + `zlib` tests. Expect moderate pass rate.
78+
- [ ] Vendor + run `crypto` tests. Expect very low pass rate (~5%) — purpose is gap documentation.
79+
- [ ] Build automated curation script: clone nodejs/node at pinned tag, filter test/parallel/ by module, static analysis for skip patterns, copy to vendored/, generate manifest.
80+
- [ ] Build CI compatibility report + ratchet enforcement. Per-module pass/fail/skip/error counts and percentages. Publish scores to `docs/nodejs-compatibility.mdx`.
81+
82+
- [ ] Add support for forking and snapshotting.
83+
- Enable isolate snapshots so a warm VM state (loaded modules, initialized globals) can be captured and restored without re-executing boot code.
84+
- Investigate V8 snapshot support in isolated-vm and/or custom serialization of module cache + global state.
85+
- Fork support: create a new execution context from an existing snapshot with copy-on-write semantics for the module cache.
86+
- Key use cases: fast cold-start for serverless, checkpoint/restore for long-running agent sessions, parallel execution from a shared base state.
87+
5788
- [ ] Fix `v8.serialize` and `v8.deserialize` to use V8 structured serialization semantics.
5889
- The current JSON-based behavior is observably wrong for `Map`, `Set`, `RegExp`, circular references, and other structured-clone cases.
5990
- Files: `packages/secure-exec/isolate-runtime/src/inject/bridge-initial-globals.ts`
@@ -125,9 +156,11 @@ Priority order is:
125156
- [ ] CLI tool E2E validation: Pi, Claude Code, and OpenCode inside sandbox.
126157
- Prove that real-world AI coding agents boot and produce output in secure-exec.
127158
- Spec: `docs-internal/specs/cli-tool-e2e.md`
128-
- Phases: Pi headless → Pi interactive/PTY → OpenCode headless (binary spawn + SDK) → OpenCode interactive/PTY → Claude Code headless → Claude Code interactive/PTY
129-
- OpenCode is a Bun binary (hardest) — tests the child_process spawn path and SDK HTTP/SSE client path (not in-VM execution); done before Claude Code to front-load risk
130-
- Prerequisite bridge gaps: controllable `isTTY`, `setRawMode()` under PTY, HTTPS client verification, Stream Transform/PassThrough, SSE/EventSource client
159+
- SDK, headless binary, and tool-use modes are passing for all three tools. Agentic workflow tests (multi-turn, npm install, npx, dev server lifecycle) also passing.
160+
- Remaining work — full TTY / interactive mode for all three tools:
161+
- [ ] Pi full TTY mode — BLOCKED: all 5 PTY tests skip. Pi CLI can't fully load in sandbox — undici requires `util/types` which is not yet bridged. Test infrastructure in place (TerminalHarness + kernel.openShell + HostBinaryDriver). Blocker: implement `util/types` bridge or workaround for undici dependency.
162+
- [ ] Claude Code full TTY mode — BLOCKED: all 6 PTY tests skip. HostBinaryDriver + TerminalHarness infrastructure is in place, but boot probe fails — Claude Code's interactive startup requires handling workspace trust dialog and API validation that the mock server doesn't fully support. Blocker: mock server needs to handle Claude's full startup handshake.
163+
- [ ] OpenCode full TTY mode — PARTIALLY WORKING: 4 of 5 PTY tests pass (TUI renders, input works, ^C works, exit works), but 'submit prompt and see response' test FAILS with waitFor timeout. Mock LLM response doesn't render on screen after submit. Also: HostBinaryDriver is copy-pasted across 3 interactive test files — needs extraction to shared module. Blocker: fix submit+response rendering through kernel PTY.
131164

132165
- [x] Review the Node driver against the intended long-term runtime contract. *(done — `.agent/contracts/node-runtime.md` and `node-bridge.md` exist)*
133166

docs/nodejs-compatibility.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,27 @@ The [project-matrix test suite](https://github.com/rivet-dev/secure-exec/tree/ma
108108

109109
To request a new package be added to the test suite, [open an issue](https://github.com/rivet-dev/secure-exec/issues/new?labels=package-request&title=Package+request:+%5Bpackage-name%5D).
110110

111+
## Known Unsupported npm Packages (Native Extensions)
112+
113+
These popular packages ship native binaries or platform-specific `.node` addons and cannot run inside a secure-exec V8 isolate. Native addons require Node's native module loader (`dlopen`), which is not available in the sandbox. The overlay module loader explicitly rejects `.node` files.
114+
115+
| Package | Weekly Downloads | Why It Fails | Pure-JS Alternative |
116+
| --- | --- | --- | --- |
117+
| [esbuild](https://npmjs.com/package/esbuild) | 116M | Spawns a platform-specific Go binary; JS API is a thin IPC wrapper | [`esbuild-wasm`](https://npmjs.com/package/esbuild-wasm) (same API, ~3x slower) |
118+
| [rollup](https://npmjs.com/package/rollup) (v4+) | 78M | Rust parser via napi-rs (`@rollup/rollup-linux-x64-gnu`, etc.) | [`@rollup/wasm-node`](https://npmjs.com/package/@rollup/wasm-node) (WASM fallback) |
119+
| [vite](https://npmjs.com/package/vite) | 65M | Hard dependency on esbuild (dep optimization) and rollup (bundling) | [webpack](https://npmjs.com/package/webpack) (pure JS) |
120+
| [tailwindcss](https://npmjs.com/package/tailwindcss) (v4) | 51M | Rust Oxide engine via napi-rs (`@tailwindcss/oxide-*`) | [`tailwindcss@3`](https://npmjs.com/package/tailwindcss) (pure JS PostCSS plugin) |
121+
| [next](https://npmjs.com/package/next) | 27M | Rust SWC compiler (`@next/swc-*`); also depends on esbuild | No pure-JS equivalent |
122+
| [sass-embedded](https://npmjs.com/package/sass-embedded) || Wraps a native Dart executable | [`sass`](https://npmjs.com/package/sass) (dart2js compiled, pure JS) |
123+
| [node-sass](https://npmjs.com/package/node-sass) || C++ LibSass binding via node-gyp (deprecated) | [`sass`](https://npmjs.com/package/sass) |
124+
| [bcrypt](https://npmjs.com/package/bcrypt) || C++ binding via node-gyp | [`bcryptjs`](https://npmjs.com/package/bcryptjs) (pure JS) |
125+
| [@swc/core](https://npmjs.com/package/@swc/core) || Rust/napi-rs transpiler | [typescript](https://npmjs.com/package/typescript) `transpileModule()` or [babel](https://npmjs.com/package/@babel/core) |
126+
| [sharp](https://npmjs.com/package/sharp) || C++ libvips binding via prebuild | [jimp](https://npmjs.com/package/jimp) (pure JS, slower) |
127+
| [better-sqlite3](https://npmjs.com/package/better-sqlite3) || C++ SQLite binding via node-gyp | [sql.js](https://npmjs.com/package/sql.js) (WASM-based SQLite) |
128+
| [canvas](https://npmjs.com/package/canvas) || C++ Cairo/Pango binding via node-gyp | [@napi-rs/canvas](https://npmjs.com/package/@napi-rs/canvas) is also native; no pure-JS equivalent |
129+
130+
Packages in the [Tested Packages](#tested-packages) table that overlap with this list (e.g. `next`, `vite`) have fixtures that test module resolution and limited API surface, not the full native build pipeline.
131+
111132
## Logging Behavior
112133

113134
- `console.log`/`warn`/`error` are supported and serialize arguments with circular-safe bounded formatting.

packages/kernel/src/kernel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,13 +458,23 @@ class KernelImpl implements Kernel {
458458
if (!stderrCb) stderrCb = (data) => stderrBuf.push(data);
459459
}
460460

461+
// Detect TTY attachment — check if each stdio FD is a PTY slave
462+
const stdinEntry = table.get(0);
463+
const stdoutEntry = table.get(1);
464+
const stderrEntry = table.get(2);
465+
461466
// Build process context with pre-wired callbacks
462467
const ctx: ProcessContext = {
463468
pid,
464469
ppid: callerPid ?? 0,
465470
env: { ...this.env, ...options?.env },
466471
cwd: options?.cwd ?? this.cwd,
467472
fds: { stdin: 0, stdout: 1, stderr: 2 },
473+
isTTY: {
474+
stdin: !!stdinEntry && this.ptyManager.isSlave(stdinEntry.description.id),
475+
stdout: !!stdoutEntry && this.ptyManager.isSlave(stdoutEntry.description.id),
476+
stderr: !!stderrEntry && this.ptyManager.isSlave(stderrEntry.description.id),
477+
},
468478
onStdout: stdoutCb,
469479
onStderr: stderrCb,
470480
};

packages/kernel/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ export interface ProcessContext {
188188
env: Record<string, string>;
189189
cwd: string;
190190
fds: { stdin: number; stdout: number; stderr: number };
191+
/** Whether stdin/stdout/stderr are connected to a PTY (terminal). */
192+
isTTY: { stdin: boolean; stdout: boolean; stderr: boolean };
191193
/** Kernel-provided callback for stdout data emitted during spawn. */
192194
onStdout?: (data: Uint8Array) => void;
193195
/** Kernel-provided callback for stderr data emitted during spawn. */

packages/kernel/test/process-table.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function createCtx(overrides?: Partial<ProcessContext>): ProcessContext {
3535
env: {},
3636
cwd: "/",
3737
fds: { stdin: 0, stdout: 1, stderr: 2 },
38+
isTTY: { stdin: false, stdout: false, stderr: false },
3839
...overrides,
3940
};
4041
}

packages/runtime/node/src/driver.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import type {
2020
import {
2121
NodeExecutionDriver,
2222
createNodeDriver,
23+
createDefaultNetworkAdapter,
2324
} from '@secure-exec/node';
2425
import {
2526
allowAllChildProcess,
2627
} from '@secure-exec/core';
2728
import type {
2829
CommandExecutor,
30+
NetworkAdapter,
2931
Permissions,
3032
VirtualFileSystem,
3133
} from '@secure-exec/core';
@@ -44,6 +46,10 @@ export interface NodeRuntimeOptions {
4446
* (fs/network/env deny-by-default). Use allowAll for full sandbox access.
4547
*/
4648
permissions?: Partial<Permissions>;
49+
/** Enable default network adapter for sandbox fetch/http. */
50+
useDefaultNetwork?: boolean;
51+
/** Custom network adapter for sandbox fetch/http (overrides useDefaultNetwork). */
52+
networkAdapter?: NetworkAdapter;
4753
}
4854

4955
/**
@@ -318,11 +324,16 @@ class NodeRuntimeDriver implements RuntimeDriver {
318324
private _kernel: KernelInterface | null = null;
319325
private _memoryLimit: number;
320326
private _permissions: Partial<Permissions>;
327+
private _moduleAccessPaths?: string[];
328+
private _networkAdapter?: NetworkAdapter;
321329
private _activeDrivers = new Map<number, NodeExecutionDriver>();
322330

323331
constructor(options?: NodeRuntimeOptions) {
324332
this._memoryLimit = options?.memoryLimit ?? 128;
325333
this._permissions = options?.permissions ?? { ...allowAllChildProcess };
334+
this._moduleAccessPaths = options?.moduleAccessPaths;
335+
this._networkAdapter = options?.networkAdapter
336+
?? (options?.useDefaultNetwork ? createDefaultNetworkAdapter() : undefined);
326337
}
327338

328339
async init(kernel: KernelInterface): Promise<void> {
@@ -435,30 +446,59 @@ class NodeRuntimeDriver implements RuntimeDriver {
435446
filesystem = createHostFallbackVfs(filesystem);
436447
}
437448

449+
// Module access: use explicit paths if provided, otherwise default
450+
const moduleAccess = this._moduleAccessPaths?.length
451+
? { cwd: this._moduleAccessPaths[0] }
452+
: undefined;
453+
438454
const systemDriver = createNodeDriver({
439455
filesystem,
440456
commandExecutor,
457+
moduleAccess,
458+
networkAdapter: this._networkAdapter,
441459
permissions: { ...this._permissions },
442460
processConfig: {
443-
cwd: ctx.cwd,
461+
// Sandbox CWD defaults to /root — the ModuleAccessFileSystem's synthetic
462+
// root where /root/node_modules maps to the host's node_modules.
463+
// The host-facing CWD (ctx.cwd) is used for command resolution, not for
464+
// in-sandbox module resolution.
444465
env: ctx.env,
445466
argv: [process.execPath, filePath ?? command, ...args],
467+
stdinIsTTY: ctx.isTTY.stdin,
468+
stdoutIsTTY: ctx.isTTY.stdout,
469+
stderrIsTTY: ctx.isTTY.stderr,
446470
},
447471
});
448472

473+
// Wire PTY setRawMode callback — when sandbox calls process.stdin.setRawMode(),
474+
// translate to kernel PTY discipline change
475+
let onPtySetRawMode: ((mode: boolean) => void) | undefined;
476+
if (ctx.isTTY.stdin && kernel) {
477+
onPtySetRawMode = (mode: boolean) => {
478+
try {
479+
kernel.ptySetDiscipline(ctx.pid, 0, {
480+
canonical: !mode,
481+
echo: !mode,
482+
});
483+
} catch { /* PTY may be gone */ }
484+
};
485+
}
486+
449487
// Create a per-process isolate
450488
const executionDriver = new NodeExecutionDriver({
451489
system: systemDriver,
452490
runtime: systemDriver.runtime,
453491
memoryLimit: this._memoryLimit,
492+
onPtySetRawMode,
454493
});
455494
this._activeDrivers.set(ctx.pid, executionDriver);
456495

457-
// Execute with stdout/stderr capture and stdin data
496+
// Execute with stdout/stderr capture and stdin data.
497+
// For inline code (-e), use a synthetic filePath under /root/ so that
498+
// __dirname is /root/ and module resolution finds /root/node_modules.
458499
const result = await executionDriver.exec(code, {
459-
filePath,
500+
filePath: filePath ?? '/root/entry.js',
460501
env: ctx.env,
461-
cwd: ctx.cwd,
462502
stdin: stdinData,
463503
onStdio: (event) => {
464504
const data = new TextEncoder().encode(event.message + '\n');

packages/runtime/node/test/driver.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ describe('Node RuntimeDriver', () => {
202202
const ctx: ProcessContext = {
203203
pid: 1, ppid: 0, env: {}, cwd: '/home/user',
204204
fds: { stdin: 0, stdout: 1, stderr: 2 },
205+
isTTY: { stdin: false, stdout: false, stderr: false },
205206
};
206207
expect(() => driver.spawn('node', ['-e', 'true'], ctx)).toThrow(/not initialized/);
207208
});

packages/runtime/python/test/driver.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ describe('Python RuntimeDriver', () => {
208208
const ctx: ProcessContext = {
209209
pid: 1, ppid: 0, env: {}, cwd: '/home/user',
210210
fds: { stdin: 0, stdout: 1, stderr: 2 },
211+
isTTY: { stdin: false, stdout: false, stderr: false },
211212
};
212213
expect(() => driver.spawn('python', ['-c', 'pass'], ctx)).toThrow(/not initialized/);
213214
});

0 commit comments

Comments
 (0)