1513 lines · 50.0 KB · TypeScript
RawDownload
/** * NodeRuntime — ergonomic façade for running guest JavaScript end-to-end. * * Boots a fully virtualized VM (via the native sidecar) and runs guest Node * programs with minimal boilerplate. All of the sidecar spawn, session * handshake, VM creation, root filesystem bootstrap, runtime-driver mounting, * and lifecycle waiting are hidden behind `NodeRuntime.create()`. * * ```ts * const rt = await NodeRuntime.create(); * const { stdout, exitCode } = await rt.exec("console.log('hi', 1 + 1)"); * await rt.dispose(); * ``` * * Guest code is written to an ESM module inside the VM and executed as * `node <file>` through the kernel, so all execution stays inside the kernel * isolation boundary — no host escapes, no real Node.js builtins for guest * work. */ import { existsSync } from "node:fs";import path from "node:path";import { fileURLToPath } from "node:url";import type {	ExecResult,	BindingDefinition,	Kernel,	KernelBootTiming,	Permissions,	VirtualDirEntry,	VirtualFileSystem,} from "./test-runtime.js";import type { JsRuntimeConfig } from "./generated/JsRuntimeConfig.js";import type { VmUserConfig } from "./generated/VmUserConfig.js";import type { SidecarProcess } from "./sidecar-process.js";import {	createKernel,	createNodeRuntime,	createWasmVmRuntime,	NodeFileSystem,} from "./test-runtime.js";import { parseNodeRuntimeCreateOptions } from "./node-runtime-options-schema.js"; export type {	BindingDefinition,	BindingExample,	VirtualDirEntry,} from "./test-runtime.js";export { resolveNodeRuntimeSidecarBinary } from "./test-runtime.js"; export type NodeRuntimeBootTimingPhase =	| KernelBootTiming["phase"]	| "runtime_mount_wasm"	| "runtime_mount_node"	| "bindings"; export interface NodeRuntimeBootTiming {	phase: NodeRuntimeBootTimingPhase;	durationMs: number;} /** Repository root, used to locate the in-repo WASM command build output. */const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); /** * In-repo build output for the WASM coreutils/shell command binaries, produced * by the Rust command build (`make -C toolchain wasm`). Only present in a * developer checkout; preferred when it exists so local edits are picked up * without re-vendoring. */const REPO_COMMANDS_DIR = path.join(	REPO_ROOT,	"toolchain/target/wasm32-wasip1/release/commands",); /** * Commands vendored into the published `@rivet-dev/agentos-runtime-core` package by * `scripts/copy-wasm-commands.mjs` (listed in `files` as `commands`). This is * the directory a real `npm install secure-exec` resolves: from the compiled * `dist/node-runtime.js` it sits at `<package>/commands`. This is the analogue * of how the sidecar binary ships inside `@rivet-dev/agentos-runtime-sidecar`. */const BUNDLED_COMMANDS_DIR = fileURLToPath(	new URL("../commands", import.meta.url),); /** * Resolve the directory holding the WASM command binaries (the source of the * guest `sh` the kernel needs to spawn any process). Precedence: * *   1. explicit `commandsDir` option, *   2. `AGENTOS_WASM_COMMANDS_DIR` env var, *   3. the in-repo build output (developer checkout), when present, *   4. the commands vendored into the installed package (published installs). * * The in-repo path wins over the bundled copy so local development picks up * freshly built commands without re-vendoring. A fresh `npm install` has no * in-repo path, so it falls through to the bundled copy. */export function resolveNodeRuntimeCommandsDir(explicit?: string): string {	if (explicit !== undefined) {		return explicit;	}	const fromEnv = process.env.AGENTOS_WASM_COMMANDS_DIR;	if (fromEnv) {		return fromEnv;	}	if (existsSync(REPO_COMMANDS_DIR)) {		return REPO_COMMANDS_DIR;	}	return BUNDLED_COMMANDS_DIR;} /** * Secure-by-default permission policy applied when the caller passes no * `permissions`. Outward-facing capabilities are denied: there is **no network * access** (and no host callbacks) by default — guest code cannot reach the * network until you opt in. The filesystem, child-process, process, and env * scopes are allowed because they are fully virtualized (the guest only ever * sees the VM's in-memory filesystem and kernel-managed processes, never the * real host) and are required for the runtime to execute a guest program at * all. Tighten or widen any scope by passing your own `permissions`. */const DEFAULT_PERMISSIONS: Permissions = {	fs: "allow",	childProcess: "allow",	process: "allow",	env: "allow",	network: "deny",}; /** * Options for {@link NodeRuntime.create}. * * Keep this public interface in sync with * `packages/core/src/node-runtime-options-schema.ts::nodeRuntimeCreateOptionsSchema`. * Options that translate into sidecar VM JSON must also stay aligned with * `crates/vm-config/src/lib.rs::CreateVmConfig`. */export interface NodeRuntimeCreateOptions {	/**	 * Caller-owned filesystem used only by this low-level compatibility runtime.	 * AgentOS clients do not create a TypeScript filesystem implicitly; normal	 * product code should use the sidecar-owned VFS through `AgentOs`.	 */	filesystem: VirtualFileSystem;	/** Environment variables visible to guest processes. */	env?: Record<string, string>;	/** Initial working directory for guest processes. Defaults to `/workspace`. */	cwd?: string;	/** Initial virtual Linux credentials and account record. Defaults to `1000:1000` (`agentos`). */	user?: VmUserConfig;	/**	 * Permission policy for the VM. Merged over a secure default that **denies	 * network access** (guest code cannot reach the network until you opt in);	 * the virtualized filesystem and processes stay enabled so programs run.	 * Because it merges, a partial policy works: `{ network: "allow" }` grants	 * the network while keeping the execution essentials. Pass a fuller policy	 * (rule sets) to further sandbox individual scopes.	 */	permissions?: Permissions;	/**	 * Override the directory containing the WASM command binaries (the source of	 * the guest `sh`). When unset, resolution falls back through the	 * `AGENTOS_WASM_COMMANDS_DIR` environment variable, the in-repo build	 * output (developer checkouts), then the commands vendored into the installed	 * `@rivet-dev/agentos-runtime-core` package (published installs).	 */	commandsDir?: string;	/**	 * Additional directories of wasm32-wasip1 commands to register in the VM.	 * Intended for low-level tooling and benchmark harnesses; normal callers use	 * the bundled shell/coreutils command directory resolved by `commandsDir`.	 */	wasmCommandDirs?: string[];	/**	 * Existing native sidecar process to use for this runtime. Omit this to use	 * the default shared sidecar behavior. When provided, the runtime owns only	 * its VM and leaves sidecar process disposal to the caller.	 */	sidecar?: SidecarProcess;	/** Receives coarse boot phase timings for benchmarks and diagnostics. */	onBootTiming?: (timing: NodeRuntimeBootTiming) => void;	/**	 * Files to seed into the VM's virtual filesystem before the guest runs,	 * keyed by absolute guest path. Parent directories are created as needed.	 * Use this to project host assets, npm packages, or fixtures into the	 * sandbox so guest code can `import`/`require`/read them. The bytes are	 * copied into the VM's in-memory filesystem; the host filesystem is never	 * exposed to the guest.	 *	 * ```ts	 * const rt = await NodeRuntime.create({	 *   files: { "/root/data.json": '{"ok":true}' },	 * });	 * ```	 */	files?: Record<string, string | Uint8Array>;	/**	 * Host directories to project into the VM's virtual filesystem, Docker-style.	 * Each mount makes a host directory readable at a guest path. Files are read	 * lazily from the host as the guest accesses them, so large trees (for	 * example a `node_modules` package such as the TypeScript compiler) are	 * projected without copying their bytes up front. The guest sees only the	 * mounted subtree, never the wider host filesystem.	 *	 * ```ts	 * const rt = await NodeRuntime.create({	 *   mounts: [	 *     {	 *       guestPath: "/root/node_modules/typescript",	 *       hostPath: "/abs/path/to/node_modules/typescript",	 *       readOnly: true,	 *     },	 *   ],	 * });	 * ```	 */	mounts?: HostDirectoryMount[];	/**	 * Mount a host `node_modules` directory into the VM in one call so guest	 * `import`/`require` resolve real, host-installed npm packages.	 *	 * Pass the absolute host path to a `node_modules` directory (or an object	 * with that path and an explicit guest location). The whole directory is	 * projected lazily, Docker-style, at a guest `node_modules` on the resolution	 * path, so any package inside it resolves the way Node would over a real	 * filesystem (ancestor `node_modules` walk, `exports`/conditions, symlinks).	 * This is the ergonomic alternative to wiring up individual `mounts` entries	 * per package.	 *	 * By default the directory is mounted at `/tmp/node_modules`, which is where	 * the resolution walk for a program run by {@link NodeRuntime.exec} /	 * {@link NodeRuntime.run} begins (each program is written under `/tmp`). Pass	 * the object form with `guestPath` to mount it elsewhere on a different	 * module's resolution path.	 *	 * ```ts	 * const rt = await NodeRuntime.create({	 *   nodeModules: "/abs/path/to/project/node_modules",	 * });	 * await rt.exec(`	 *   import isNumber from "is-number";	 *   console.log(isNumber(42));	 * `);	 * ```	 *	 * The host filesystem is never exposed beyond the mounted `node_modules`	 * subtree. The mount is read-only.	 */	nodeModules?: string | NodeModulesMount;	/**	 * Host-side bindings the guest can invoke as shell commands. Each entry is	 * registered as a named guest command; when the guest runs it, the	 * invocation round-trips back to the host and runs the binding's `handler`,	 * whose return value is delivered back to the guest. This is how you give	 * sandboxed guest code controlled, named host capabilities (the kind an AI	 * agent calls as tools) without granting it the underlying access directly.	 *	 * The guest invokes a binding by name with JSON input:	 *	 * ```ts	 * const rt = await NodeRuntime.create({	 *   bindings: {	 *     add: {	 *       description: "Add two numbers",	 *       inputSchema: {	 *         type: "object",	 *         properties: { a: { type: "number" }, b: { type: "number" } },	 *         required: ["a", "b"],	 *       },	 *       handler: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),	 *     },	 *   },	 * });	 * await rt.exec(`	 *   import { execFileSync } from "node:child_process";	 *   const out = execFileSync("add", ["add", "--json", JSON.stringify({ a: 2, b: 3 })]);	 *   console.log(out.toString());	 * `);	 * ```	 *	 * When `bindings` is provided and no `binding` permission scope is set, the	 * `binding` scope is granted so the registered bindings are invocable; pass	 * your own `permissions.binding` policy to gate individual bindings.	 */	bindings?: Record<string, BindingDefinition>;	/**	 * Guest-bound ports that may accept non-loopback connections. By default a	 * guest server is reachable only over loopback inside the VM; listing a port	 * here lifts that restriction for that port, letting connections from outside	 * the loopback interface reach it. Use this for guests that run servers which	 * must accept external connections (for example a dev server you expose	 * beyond loopback).	 *	 * ```ts	 * const rt = await NodeRuntime.create({	 *   permissions: { network: "allow" },	 *   loopbackExemptPorts: [3000],	 * });	 * ```	 */	loopbackExemptPorts?: number[];	/**	 * Low-level guest JavaScript runtime configuration. Most callers should leave	 * this unset. Benchmarks may opt in to `highResolutionTime`, which disables	 * the default 1ms timer quantization and should not be enabled for untrusted	 * workloads.	 */	jsRuntime?: Partial<JsRuntimeConfig>;} /** A host directory projected into the VM's virtual filesystem. */export interface HostDirectoryMount {	/** Absolute guest path the directory appears at inside the VM. */	guestPath: string;	/** Absolute host directory to project (read through the VFS, lazily). */	hostPath: string;	/** Mount read-only (the default). Pass `false` to allow guest writes. */	readOnly?: boolean;} /** Guest path a `nodeModules` mount is projected at by default. */const DEFAULT_NODE_MODULES_GUEST_PATH = "/tmp/node_modules"; /** * Object form of the `nodeModules` create option: a host `node_modules` * directory to project, optionally at an explicit guest path. The string form * (`nodeModules: "/abs/node_modules"`) is shorthand for `{ hostPath }`. */export interface NodeModulesMount {	/** Absolute host `node_modules` directory to project (read lazily). */	hostPath: string;	/**	 * Absolute guest path to mount it at. Defaults to `/tmp/node_modules`, where	 * the resolution walk for {@link NodeRuntime.exec} / {@link NodeRuntime.run}	 * programs begins. Override to put it on a different module's resolution path.	 */	guestPath?: string;} /** Result of {@link NodeRuntime.exec}. */export interface NodeRuntimeExecResult {	stdout: string;	stderr: string;	exitCode: number;} /** Options for a single {@link NodeRuntime.exec} call. */export interface NodeRuntimeExecOptions {	/** Extra environment variables for this run, merged over the VM env. */	env?: Record<string, string>;	/** Working directory for this run. */	cwd?: string;	/** Data piped to the guest program's stdin. */	stdin?: string | Uint8Array;	/** Abort the run after this many milliseconds. */	timeout?: number;	/**	 * Cancel the run when this signal aborts. On abort the guest process is	 * killed inside the VM (the kernel delivers `SIGTERM`) and the call rejects	 * with the signal's abort reason. Use this to cancel an in-flight run from	 * the outside, for example to enforce your own deadline or stop work when a	 * request is canceled.	 *	 * ```ts	 * const controller = new AbortController();	 * const pending = rt.exec("while (true) {}", { signal: controller.signal });	 * controller.abort();	 * await pending.catch((err) => console.log(err.name)); // "AbortError"	 * ```	 */	signal?: AbortSignal;	/**	 * Called with each chunk the guest writes to stdout as it is produced,	 * letting you observe output incrementally instead of waiting for the run to	 * finish. Chunks arrive as raw bytes; decode with a `TextDecoder` for text.	 * The complete output is still returned as `result.stdout` when the run ends.	 */	onStdout?: (chunk: Uint8Array) => void;	/**	 * Called with each chunk the guest writes to stderr as it is produced,	 * letting you observe output incrementally instead of waiting for the run to	 * finish. Chunks arrive as raw bytes; decode with a `TextDecoder` for text.	 * The complete output is still returned as `result.stderr` when the run ends.	 */	onStderr?: (chunk: Uint8Array) => void;} /** The HTTP request {@link NodeRuntime.fetch} drives into the VM. */export interface NodeRuntimeFetchInput {	/** HTTP method. Defaults to `GET`. */	method?: string;	/** Request path (and query), e.g. `/api/users?limit=10`. */	path: string;	/** Request headers. */	headers?: Record<string, string>;	/** Request body. */	body?: string | Uint8Array;} /** The HTTP response {@link NodeRuntime.fetch} returns from the VM. */export interface NodeRuntimeFetchResponse {	/** HTTP status code, e.g. `200`. */	status: number;	/** HTTP status text, e.g. `OK`. */	statusText: string;	/** Response headers, lower-cased by name. */	headers: Record<string, string>;	/** Response body decoded as UTF-8 text. */	body: string;} /** * Options for {@link NodeRuntime.spawn}. Inherits the streaming `onStdout` / * `onStderr` hooks from {@link NodeRuntimeExecOptions}. */export interface NodeRuntimeSpawnOptions extends NodeRuntimeExecOptions {} /** * Describes the guest TCP listener to wait for with * {@link NodeRuntime.waitForListener} or look up with * {@link NodeRuntime.findListener}. A listener matches when a guest process is * accepting connections on the given `port` (and `host`/`path` when supplied). */export interface NodeRuntimeListenerQuery {	/** TCP port the guest listener is bound to, e.g. `3000`. */	port: number;	/** Bind host to match, e.g. `127.0.0.1`. Omit to match any host. */	host?: string;	/** Unix socket path to match, for path-bound listeners. */	path?: string;} /** * A matched guest listener returned by {@link NodeRuntime.waitForListener} and * {@link NodeRuntime.findListener}. `processId` identifies the guest process * that owns the listening socket; the `host`/`port`/`path` it is bound to are * reported when known. */export interface NodeRuntimeListener {	/** The guest process id that owns the listening socket. */	processId: string;	/** The host the listener is bound to, when reported. */	host?: string;	/** The port the listener is bound to, when reported. */	port?: number;	/** The unix socket path the listener is bound to, when reported. */	path?: string;} /** Options for {@link NodeRuntime.waitForListener}. */export interface NodeRuntimeWaitForListenerOptions {	/**	 * Give up after this many milliseconds and reject. Defaults to 10000. The	 * wait also rejects if the bound `signal` aborts first.	 */	timeoutMs?: number;	/** Abort the wait early; the returned promise rejects when it fires. */	signal?: AbortSignal;	/**	 * How long to wait between listener lookups while polling, in milliseconds.	 * Defaults to 50.	 */	pollIntervalMs?: number;} /** * A live handle to a guest process started with {@link NodeRuntime.spawn}. * * Unlike {@link NodeRuntime.exec}, which runs a program to completion and * returns its captured output, a handle is returned immediately while the * process keeps running. Use it to stream stdout/stderr, feed stdin, signal or * kill the process, and await its exit. This is the building block for * long-running guests such as dev servers: start one here, then drive requests * into it with {@link NodeRuntime.fetch}. */export interface NodeRuntimeProcess {	/** The guest process id. */	readonly pid: number;	/** Write data to the guest process's stdin. */	writeStdin(data: string | Uint8Array): void;	/** Close the guest process's stdin, signalling end-of-input. */	closeStdin(): void;	/**	 * Send a signal to the guest process. Defaults to `SIGTERM`. Accepts a	 * signal name (e.g. `"SIGKILL"`) or a raw signal number.	 */	kill(signal?: NodeJS.Signals | number): void;	/** Resolve with the guest process's exit code once it terminates. */	wait(): Promise<number>;	/** The exit code once the process has exited, or `null` while it runs. */	readonly exitCode: number | null;} export interface NodeRuntimeResourceSnapshot {	runningProcesses: number;	exitedProcesses: number;	fdTables: number;	openFds: number;

Lines 1–500 of 1513.