Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

- every publishable package must include a `README.md` with the standard format: title, tagline, and links to website, docs, and GitHub
- if `package.json` has a `"files"` array, `"README.md"` must be listed in it
- **no hardcoded monorepo/pnpm paths** — NEVER resolve dependencies at runtime using hardcoded relative paths into `node_modules/.pnpm/` or monorepo-relative `../../../node_modules/` walks; use `createRequire(import.meta.url).resolve("pkg/path")` or standard Node module resolution instead
- **no phantom transitive dependencies** — if published runtime code calls `require.resolve("foo")` or `import("foo")`, `foo` MUST be declared in that package's `dependencies` (not just available transitively in the monorepo)
- **`files` array must cover all runtime references** — if compiled `dist/` code resolves paths outside `dist/` at runtime (e.g., `../src/polyfills/`), those directories MUST be listed in the `"files"` array; verify with `pnpm pack --json` or `npm pack --dry-run` before publishing

## Testing Policy

Expand All @@ -24,6 +27,11 @@
- real-provider NodeRuntime CLI/tool tests that need a mutable temp worktree must pair `moduleAccess` with a real host-backed base filesystem such as `new NodeFileSystem()`; `moduleAccess` alone makes projected packages readable but leaves sandbox tools unable to touch `/tmp` working files
- e2e-docker fixtures connect to real Docker containers (Postgres, MySQL, Redis, SSH/SFTP) — skip gracefully via `skipUnlessDocker()` when Docker is unavailable
- interactive/PTY tests must use `kernel.openShell()` with `@xterm/headless`, not host PTY via `script -qefc`
- before fixing a reported runtime, CLI, SDK, or PTY bug, first reproduce the broken state and capture the exact visible output (stdout, stderr, event payloads, or terminal screen) in a regression or work note; do not start by guessing at the fix
- terminal-output and PTY-rendering bugs must use snapshot-style assertions against exact strings or exact screen contents under fixed rows/cols, not loose substring checks
- if expected terminal behavior is unclear, run the same flow on the host as a control and compare the sandbox transcript/screen against that host output before deciding what to fix
- be liberal with structured debug logging for complex interactive or long-running sessions so later manual repros can be diagnosed from artifacts instead of memory
- debug logging for complex sessions should go to a separate sink that does not contaminate stdout/stderr protocol output; prefer structured `pino` logs with enough context to reconstruct process lifecycle, PTY events, command routing, and failures, while redacting secrets
- kernel blocking-I/O regressions should be proven through `packages/core/test/kernel/kernel-integration.test.ts` using real process-owned FDs via `KernelInterface` (`fdWrite`, `flock`, `fdPollWait`) rather than only manager-level unit tests
- inode-lifetime/deferred-unlink kernel integration tests must use `InMemoryFileSystem` (or another inode-aware VFS) and await the kernel's POSIX-dir bootstrap; the default `createTestKernel()` `TestFileSystem` does not exercise inode-backed FD lifetime semantics
- kernel signal-handler regressions should use a real spawned PID plus `KernelInterface.processTable` / `KernelInterface.socketTable`; unit `ProcessTable` coverage alone does not prove pending delivery or `SA_RESTART` behavior through the live kernel
Expand Down
21 changes: 21 additions & 0 deletions docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ createNodeDriver(options?: NodeDriverOptions): SystemDriver
| `commandExecutor` | `CommandExecutor` | Child process executor. |
| `permissions` | `Permissions` | Access control rules. Deny-by-default. |
| `useDefaultNetwork` | `boolean` | Enable default Node.js network adapter. |
| `loopbackExemptPorts` | `number[]` | Loopback ports that bypass SSRF checks (with `useDefaultNetwork`). |
| `processConfig` | `ProcessConfig` | Process metadata (cwd, env, argv, etc.). |
| `osConfig` | `OSConfig` | OS metadata (platform, arch, homedir, etc.). |

Expand Down Expand Up @@ -268,6 +269,26 @@ Each field accepts a `PermissionCheck`, which is either a boolean or a function

---

## Execution Methods

### `runtime.exec()`

Process-style execution. Accepts per-call environment, working directory, stdin, and stdio hooks. Use for automation loops, output observation, and CLI-style integrations.

```ts
exec(code: string, options?: ExecOptions): Promise<ExecResult>
```

### `runtime.run()`

Export-based evaluation. Returns the sandbox module's exports. Use when the sandbox should compute and return a value.

```ts
run<T = unknown>(code: string, filePath?: string): Promise<RunResult<T>>
```

---

## Execution Types

### `ExecOptions` (NodeRuntime)
Expand Down
29 changes: 29 additions & 0 deletions docs/features/networking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,35 @@ const driver = createNodeDriver({
| `dnsLookup(hostname)` | `Promise<DnsResult>` | DNS resolution |
| `httpRequest(url, options?)` | `Promise<HttpResponse>` | Low-level HTTP request |

## Loopback RPC exemptions

The default network adapter blocks all loopback/private-IP requests as SSRF protection. To allow sandbox code to call a host-side RPC server on specific loopback ports, use `loopbackExemptPorts`:

```ts
import { createNodeDriver, allowAllNetwork } from "secure-exec";

const driver = createNodeDriver({
useDefaultNetwork: true,
loopbackExemptPorts: [rpcPort],
permissions: { ...allowAllNetwork },
});
```

Only the listed ports are exempt — all other loopback and private-IP requests remain blocked.

If you need more control (e.g. dynamic port discovery), construct the adapter directly:

```ts
import { createNodeDriver, createDefaultNetworkAdapter, allowAllNetwork } from "secure-exec";

const driver = createNodeDriver({
networkAdapter: createDefaultNetworkAdapter({
initialExemptPorts: [rpcPort],
}),
permissions: { ...allowAllNetwork },
});
```

## Permission gating

Use a function to filter requests:
Expand Down
2 changes: 2 additions & 0 deletions docs/features/output-capture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ icon: "message-lines"

Console output from sandboxed code is **not buffered** into result fields. `exec()` and `run()` do not return `stdout` or `stderr`. Use the `onStdio` hook to capture output.

The per-execution `onStdio` option is available on `exec()` only. To capture output from `run()` calls, set a runtime-level hook when creating the `NodeRuntime` (see [Default hook](#default-hook) below).

## Runnable example

```ts
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ icon: "rocket"
</Step>

<Step title="Run code">
Use `runtime.run()` to execute JavaScript and get back exported values. Use `runtime.exec()` for scripts that produce console output.
Use `runtime.run()` to execute JavaScript and get back exported values. Use `runtime.exec()` for process-style execution with stdout/stderr observation, per-call environment overrides, and automation loops.

<CodeGroup>
```ts Simple
Expand Down
77 changes: 73 additions & 4 deletions docs/runtimes/node.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,32 +67,101 @@ By default, all runtimes share a single V8 child process. You can pass a dedicat

## exec vs run

Use `exec()` when you care about side effects (logging, file writes) but don't need a return value.
`NodeRuntime` exposes two execution methods with different signatures and intended use cases:

```ts
const result = await runtime.exec("const label = 'done'; console.log(label)");
console.log(result.code); // 0
// Process-style execution — observe stdout/stderr, set env/cwd/stdin
exec(code: string, options?: ExecOptions): Promise<ExecResult>

// Export-based evaluation — get computed values back
run<T>(code: string, filePath?: string): Promise<RunResult<T>>
```

| | `exec()` | `run()` |
|---|---|---|
| **Returns** | `{ code, errorMessage? }` | `{ code, errorMessage?, exports? }` |
| **Per-call `onStdio`** | Yes | No (use runtime-level hook) |
| **Per-call `env` / `cwd` / `stdin`** | Yes | No |
| **Best for** | Side effects, CLI-style output, automation loops | Getting computed values back into the host |

### When to use `exec()`

Use `exec()` when sandboxed code produces **output you need to observe** or when you need per-call control over the execution environment. This is the right choice for AI SDK tool loops, code interpreters, and any integration where the result is communicated through `console.log` rather than `export`.

```ts
// AI SDK tool loop — capture stdout from each step
for (const step of toolSteps) {
const result = await runtime.exec(step.code, {
onStdio: (e) => appendToToolResult(e.message),
env: { API_KEY: step.apiKey },
cwd: "/workspace",
});
if (result.code !== 0) handleError(result);
}
```

Use `run()` when you need a value back. The sandboxed code should use `export default`.
### When to use `run()`

Use `run()` when sandboxed code **exports a value** you need in the host. The sandbox code uses `export default` or named exports, and the host reads them from `result.exports`.

```ts
// Evaluate a user-provided expression and get the result
const result = await runtime.run<{ default: number }>("export default 40 + 2");
console.log(result.exports?.default); // 42
```

<Tip>
If you find yourself parsing `console.log` output to extract a value, switch to `run()` with an `export`. If you need to watch a stream of output lines, switch to `exec()` with `onStdio`.
</Tip>

## Capturing output

Console output is not buffered into the result. Use the `onStdio` hook to capture it.

The per-execution `onStdio` option is available on `exec()` only. To capture output from `run()`, set a runtime-level hook:

```ts
// Per-execution hook (exec only)
const logs: string[] = [];
await runtime.exec("console.log('hello'); console.error('oops')", {
onStdio: (event) => logs.push(`[${event.channel}] ${event.message}`),
});
// logs: ["[stdout] hello", "[stderr] oops"]

// Runtime-level hook (applies to both exec and run)
const runtime = new NodeRuntime({
systemDriver: createNodeDriver(),
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
onStdio: (event) => console.log(event.message),
});
```

## Lifecycle

A single `NodeRuntime` instance is designed to be reused across many `.exec()` and `.run()` calls. Each call creates a fresh V8 isolate session internally, so per-execution state (module cache, budgets) is automatically reset while the underlying V8 process is reused efficiently.

```ts
// Recommended: create once, call many times, dispose at the end
const runtime = new NodeRuntime({
systemDriver: createNodeDriver(),
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
});

// AI SDK tool loop — each step reuses the same runtime
for (const step of toolSteps) {
const result = await runtime.exec(step.code, {
onStdio: (e) => log(e.message),
});
}

// Clean up when the session is over
runtime.dispose();
```

Do **not** dispose and recreate the runtime between sequential calls. Calling `.exec()` or `.run()` on a disposed runtime throws `"NodeExecutionDriver has been disposed"`.

`dispose()` is synchronous and immediate — it kills active child processes and clears timers. Use `terminate()` (async) when you need to wait for graceful HTTP server shutdown before cleanup.

## TypeScript workflows

`NodeRuntime` executes JavaScript only. For sandboxed TypeScript type checking or compilation, use the separate `@secure-exec/typescript` package. See [TypeScript support](#typescript-support).
Expand Down
6 changes: 4 additions & 2 deletions docs/sdk-overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,19 @@ All host capabilities are deny-by-default. You opt in to what sandboxed code can
Two methods for running sandboxed code:

```ts
// exec() runs code for side effects, returns an exit code
// exec() — process-style execution with stdout/stderr observation
const execResult = await runtime.exec("console.log('hello')");
console.log(execResult.code); // 0

// run() runs code and returns the default export
// run() — export-based evaluation, returns computed values
const runResult = await runtime.run<{ default: number }>(
"export default 2 + 2"
);
console.log(runResult.exports?.default); // 4
```

Use `exec()` for automation loops, CLI-style output capture, and per-call environment overrides. Use `run()` when the sandbox should return a value via `export`. See [exec vs run](/runtimes/node#exec-vs-run) for the full comparison.

## Capture output

Console output is not buffered by default. Use the `onStdio` hook to capture it:
Expand Down
1 change: 1 addition & 0 deletions docs/system-drivers/node.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const driver = createNodeDriver({
| `commandExecutor` | `CommandExecutor` | Custom command executor for child processes (see [Child processes](#child-processes)). |
| `permissions` | `Permissions` | Permission callbacks for fs, network, child process, and env access. |
| `useDefaultNetwork` | `boolean` | Use the built-in network adapter (fetch, DNS, HTTP client). |
| `loopbackExemptPorts` | `number[]` | Loopback ports that bypass SSRF checks when using the default network adapter. |
| `processConfig` | `ProcessConfig` | Values for `process.cwd()`, `process.env`, etc. inside the sandbox. |
| `osConfig` | `OSConfig` | Values for `os.platform()`, `os.arch()`, etc. inside the sandbox. |

Expand Down
22 changes: 22 additions & 0 deletions native/wasmvm/patches/wasi-libc-overrides/init_cwd.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Initialize process cwd from PWD environment variable.
*
* WASI processes start with __wasilibc_cwd = "/" (from preopened directory
* scanning). The kernel sets PWD in each spawned process's environment to
* match the intended cwd. This constructor reads PWD and calls chdir()
* to synchronize wasi-libc's internal cwd state with the kernel's.
*
* Installed into the patched sysroot so ALL WASM programs get correct
* initial cwd, not just test binaries.
*/

#include <stdlib.h>
#include <unistd.h>

__attribute__((constructor, used))
static void __init_cwd_from_pwd(void) {
const char *pwd = getenv("PWD");
if (pwd && pwd[0] == '/') {
chdir(pwd);
}
}
52 changes: 52 additions & 0 deletions native/wasmvm/patches/wasi-libc/0012-posix-spawn-cwd.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
Fix posix_spawn to propagate cwd to child processes.

posix_spawn previously passed an empty cwd (len=0) to proc_spawn,
causing children to fall back to the kernel-worker's init.cwd instead
of the parent's current working directory. This fix:

1. Processes FDOP_CHDIR file_actions to capture explicit cwd overrides
2. Falls back to getcwd() when no explicit cwd is set
3. Passes the resolved cwd to proc_spawn

This complements the kernel-side fix (setting PWD in env) and the
init_cwd.c constructor (reading PWD at WASM startup) to ensure
full cwd propagation from parent shell to spawned commands.

--- a/libc-bottom-half/sources/host_spawn_wait.c
+++ b/libc-bottom-half/sources/host_spawn_wait.c
@@ -252,6 +252,7 @@
}

// Process file_actions in order: extract stdio overrides and handle close/open
+ const char *spawn_cwd = NULL;
uint32_t stdin_fd = 0, stdout_fd = 1, stderr_fd = 2;
if (fa && fa->__actions) {
for (struct __fdop *op = fa->__actions; op; op = op->next) {
@@ -279,15 +280,24 @@
else close(opened);
break;
}
+ case FDOP_CHDIR:
+ spawn_cwd = op->path;
+ break;
}
}
}

+ // Resolve cwd: explicit chdir action > current getcwd > empty (kernel fallback)
+ char cwd_buf[1024];
+ const char *cwd_str = spawn_cwd;
+ if (!cwd_str && getcwd(cwd_buf, sizeof(cwd_buf))) {
+ cwd_str = cwd_buf;
+ }
+
uint32_t child_pid;
uint32_t err = __host_proc_spawn(
argv_buf, (uint32_t)argv_buf_len,
envp_buf ? envp_buf : (const uint8_t *)"", (uint32_t)envp_buf_len,
stdin_fd, stdout_fd, stderr_fd,
- (const uint8_t *)"", 0,
+ cwd_str ? (const uint8_t *)cwd_str : (const uint8_t *)"", cwd_str ? (uint32_t)strlen(cwd_str) : 0,
&child_pid);

free(argv_buf);
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
Kernel,
KernelOptions,
KernelInterface,
KernelLogger,
ExecOptions as KernelExecOptions,
ExecResult as KernelExecResult,
SpawnOptions as KernelSpawnOptions,
Expand Down Expand Up @@ -31,7 +32,7 @@ export type {
ConnectTerminalOptions,
Permissions,
} from "./kernel/types.js";
export { KernelError, defaultTermios } from "./kernel/types.js";
export { KernelError, defaultTermios, noopKernelLogger } from "./kernel/types.js";
export type {
VirtualFileSystem,
VirtualDirEntry,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
Kernel,
KernelOptions,
KernelInterface,
KernelLogger,
ExecOptions,
ExecResult,
SpawnOptions,
Expand Down Expand Up @@ -45,8 +46,8 @@ export type {
ConnectTerminalOptions,
} from "./types.js";

// Structured kernel error and termios defaults
export { KernelError, defaultTermios } from "./types.js";
// Structured kernel error, termios defaults, and no-op logger
export { KernelError, defaultTermios, noopKernelLogger } from "./types.js";

// VFS types
export type {
Expand Down
Loading
Loading