@secure-exec/core 0.2.1 → 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +5 -5
  2. package/dist/binary.d.ts +4 -0
  3. package/dist/binary.js +25 -0
  4. package/dist/bytes.d.ts +2 -0
  5. package/dist/bytes.js +6 -0
  6. package/dist/callbacks.d.ts +41 -0
  7. package/dist/callbacks.js +94 -0
  8. package/dist/cargo.d.ts +2 -0
  9. package/dist/cargo.js +142 -0
  10. package/dist/correlation.d.ts +10 -0
  11. package/dist/correlation.js +49 -0
  12. package/dist/descriptors.d.ts +34 -0
  13. package/dist/descriptors.js +37 -0
  14. package/dist/event-buffer.d.ts +90 -0
  15. package/dist/event-buffer.js +313 -0
  16. package/dist/ext.d.ts +7 -0
  17. package/dist/ext.js +13 -0
  18. package/dist/filesystem.d.ts +41 -0
  19. package/dist/filesystem.js +70 -0
  20. package/dist/frame-payload-codec.d.ts +8 -0
  21. package/dist/frame-payload-codec.js +14 -0
  22. package/dist/frame-rpc.d.ts +38 -0
  23. package/dist/frame-rpc.js +73 -0
  24. package/dist/frame-stream.d.ts +27 -0
  25. package/dist/frame-stream.js +99 -0
  26. package/dist/framing.d.ts +7 -0
  27. package/dist/framing.js +22 -0
  28. package/dist/generated/AcpLimitsConfig.d.ts +4 -0
  29. package/dist/generated/AcpLimitsConfig.js +2 -0
  30. package/dist/generated/CreateVmConfig.d.ts +19 -0
  31. package/dist/generated/FsPermissionRule.d.ts +6 -0
  32. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  33. package/dist/generated/FsPermissionRuleSet.js +1 -0
  34. package/dist/generated/FsPermissionScope.d.ts +3 -0
  35. package/dist/generated/FsPermissionScope.js +1 -0
  36. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  37. package/dist/generated/HttpLimitsConfig.js +2 -0
  38. package/dist/generated/JsModuleResolution.d.ts +1 -0
  39. package/dist/generated/JsModuleResolution.js +2 -0
  40. package/dist/generated/JsRuntimeConfig.d.ts +26 -0
  41. package/dist/generated/JsRuntimeConfig.js +1 -0
  42. package/dist/generated/JsRuntimeLimitsConfig.d.ts +7 -0
  43. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  44. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  45. package/dist/generated/JsRuntimePlatform.js +2 -0
  46. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  47. package/dist/generated/MountPluginDescriptor.js +2 -0
  48. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  49. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  50. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  51. package/dist/generated/PatternPermissionRule.js +1 -0
  52. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  53. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  54. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  55. package/dist/generated/PatternPermissionScope.js +1 -0
  56. package/dist/generated/PermissionMode.d.ts +1 -0
  57. package/dist/generated/PermissionMode.js +2 -0
  58. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  59. package/dist/generated/PermissionsPolicy.js +1 -0
  60. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  61. package/dist/generated/PluginLimitsConfig.js +2 -0
  62. package/dist/generated/PythonLimitsConfig.d.ts +5 -0
  63. package/dist/generated/PythonLimitsConfig.js +2 -0
  64. package/dist/generated/ResourceLimitsConfig.d.ts +22 -0
  65. package/dist/generated/ResourceLimitsConfig.js +2 -0
  66. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  67. package/dist/generated/RootFilesystemConfig.js +1 -0
  68. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  69. package/dist/generated/RootFilesystemEntry.js +1 -0
  70. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  71. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  72. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  73. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  74. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  75. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  76. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  77. package/dist/generated/RootFilesystemMode.js +2 -0
  78. package/dist/generated/ToolLimitsConfig.d.ts +10 -0
  79. package/dist/generated/ToolLimitsConfig.js +2 -0
  80. package/dist/generated/VmDnsConfig.d.ts +6 -0
  81. package/dist/generated/VmDnsConfig.js +2 -0
  82. package/dist/generated/VmLimitsConfig.d.ts +18 -0
  83. package/dist/generated/VmLimitsConfig.js +1 -0
  84. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  85. package/dist/generated/VmListenPolicyConfig.js +2 -0
  86. package/dist/generated/WasmLimitsConfig.d.ts +5 -0
  87. package/dist/generated/WasmLimitsConfig.js +2 -0
  88. package/dist/generated-protocol.d.ts +1037 -0
  89. package/dist/generated-protocol.js +2887 -0
  90. package/dist/index.d.ts +24 -62
  91. package/dist/index.js +24 -53
  92. package/dist/json.d.ts +2 -0
  93. package/dist/json.js +20 -0
  94. package/dist/kernel-proxy.d.ts +149 -0
  95. package/dist/kernel-proxy.js +1733 -0
  96. package/dist/native-client.d.ts +41 -0
  97. package/dist/native-client.js +124 -0
  98. package/dist/node-runtime.d.ts +443 -0
  99. package/dist/node-runtime.js +569 -0
  100. package/dist/numbers.d.ts +1 -0
  101. package/dist/numbers.js +8 -0
  102. package/dist/ownership.d.ts +18 -0
  103. package/dist/ownership.js +77 -0
  104. package/dist/permissions.d.ts +29 -0
  105. package/dist/permissions.js +68 -0
  106. package/dist/process.d.ts +35 -0
  107. package/dist/process.js +125 -0
  108. package/dist/protocol-client.d.ts +46 -0
  109. package/dist/protocol-client.js +180 -0
  110. package/dist/protocol-frames.d.ts +68 -0
  111. package/dist/protocol-frames.js +139 -0
  112. package/dist/protocol-maps.d.ts +28 -0
  113. package/dist/protocol-maps.js +217 -0
  114. package/dist/protocol-schema.d.ts +10 -0
  115. package/dist/protocol-schema.js +11 -0
  116. package/dist/request-payloads.d.ts +137 -0
  117. package/dist/request-payloads.js +210 -0
  118. package/dist/response-payloads.d.ts +107 -0
  119. package/dist/response-payloads.js +161 -0
  120. package/dist/sidecar-client.d.ts +242 -0
  121. package/dist/sidecar-client.js +797 -0
  122. package/dist/state.d.ts +40 -0
  123. package/dist/state.js +44 -0
  124. package/dist/test-runtime.d.ts +526 -0
  125. package/dist/test-runtime.js +2119 -0
  126. package/dist/vm-config.d.ts +31 -0
  127. package/dist/vm-config.js +1 -0
  128. package/fixtures/alpine-defaults.json +520 -0
  129. package/fixtures/base-filesystem.json +528 -0
  130. package/package.json +193 -115
  131. package/LICENSE +0 -191
  132. package/dist/bridge-setup.d.ts +0 -6
  133. package/dist/bridge-setup.js +0 -9
  134. package/dist/esm-compiler.d.ts +0 -18
  135. package/dist/esm-compiler.js +0 -72
  136. package/dist/fs-helpers.d.ts +0 -23
  137. package/dist/fs-helpers.js +0 -41
  138. package/dist/generated/isolate-runtime.d.ts +0 -19
  139. package/dist/generated/isolate-runtime.js +0 -21
  140. package/dist/generated/polyfills.d.ts +0 -82
  141. package/dist/generated/polyfills.js +0 -82
  142. package/dist/isolate-runtime/apply-custom-global-policy.js +0 -53
  143. package/dist/isolate-runtime/apply-timing-mitigation-freeze.js +0 -130
  144. package/dist/isolate-runtime/apply-timing-mitigation-off.js +0 -14
  145. package/dist/isolate-runtime/bridge-attach.js +0 -29
  146. package/dist/isolate-runtime/bridge-initial-globals.js +0 -385
  147. package/dist/isolate-runtime/eval-script-result.js +0 -8
  148. package/dist/isolate-runtime/global-exposure-helpers.js +0 -36
  149. package/dist/isolate-runtime/init-commonjs-module-globals.js +0 -28
  150. package/dist/isolate-runtime/override-process-cwd.js +0 -8
  151. package/dist/isolate-runtime/override-process-env.js +0 -8
  152. package/dist/isolate-runtime/require-setup.js +0 -4153
  153. package/dist/isolate-runtime/set-commonjs-file-globals.js +0 -36
  154. package/dist/isolate-runtime/set-stdin-data.js +0 -10
  155. package/dist/isolate-runtime/setup-dynamic-import.js +0 -123
  156. package/dist/isolate-runtime/setup-fs-facade.js +0 -87
  157. package/dist/kernel/command-registry.d.ts +0 -44
  158. package/dist/kernel/command-registry.js +0 -114
  159. package/dist/kernel/device-backend.d.ts +0 -14
  160. package/dist/kernel/device-backend.js +0 -251
  161. package/dist/kernel/device-layer.d.ts +0 -12
  162. package/dist/kernel/device-layer.js +0 -271
  163. package/dist/kernel/dns-cache.d.ts +0 -29
  164. package/dist/kernel/dns-cache.js +0 -52
  165. package/dist/kernel/fd-table.d.ts +0 -84
  166. package/dist/kernel/fd-table.js +0 -278
  167. package/dist/kernel/file-lock.d.ts +0 -34
  168. package/dist/kernel/file-lock.js +0 -122
  169. package/dist/kernel/host-adapter.d.ts +0 -50
  170. package/dist/kernel/host-adapter.js +0 -8
  171. package/dist/kernel/index.d.ts +0 -36
  172. package/dist/kernel/index.js +0 -34
  173. package/dist/kernel/kernel.d.ts +0 -9
  174. package/dist/kernel/kernel.js +0 -1415
  175. package/dist/kernel/mount-table.d.ts +0 -75
  176. package/dist/kernel/mount-table.js +0 -353
  177. package/dist/kernel/permissions.d.ts +0 -36
  178. package/dist/kernel/permissions.js +0 -150
  179. package/dist/kernel/pipe-manager.d.ts +0 -64
  180. package/dist/kernel/pipe-manager.js +0 -267
  181. package/dist/kernel/proc-backend.d.ts +0 -30
  182. package/dist/kernel/proc-backend.js +0 -428
  183. package/dist/kernel/proc-layer.d.ts +0 -11
  184. package/dist/kernel/proc-layer.js +0 -507
  185. package/dist/kernel/process-table.d.ts +0 -126
  186. package/dist/kernel/process-table.js +0 -651
  187. package/dist/kernel/pty.d.ts +0 -109
  188. package/dist/kernel/pty.js +0 -552
  189. package/dist/kernel/socket-table.d.ts +0 -312
  190. package/dist/kernel/socket-table.js +0 -1188
  191. package/dist/kernel/timer-table.d.ts +0 -54
  192. package/dist/kernel/timer-table.js +0 -108
  193. package/dist/kernel/types.d.ts +0 -541
  194. package/dist/kernel/types.js +0 -98
  195. package/dist/kernel/user.d.ts +0 -29
  196. package/dist/kernel/user.js +0 -35
  197. package/dist/kernel/vfs.d.ts +0 -82
  198. package/dist/kernel/vfs.js +0 -25
  199. package/dist/kernel/wait.d.ts +0 -45
  200. package/dist/kernel/wait.js +0 -112
  201. package/dist/kernel/wstatus.d.ts +0 -21
  202. package/dist/kernel/wstatus.js +0 -33
  203. package/dist/module-resolver.d.ts +0 -29
  204. package/dist/module-resolver.js +0 -314
  205. package/dist/package-bundler.d.ts +0 -41
  206. package/dist/package-bundler.js +0 -497
  207. package/dist/runtime-driver.d.ts +0 -66
  208. package/dist/shared/api-types.d.ts +0 -83
  209. package/dist/shared/bridge-contract.d.ts +0 -772
  210. package/dist/shared/bridge-contract.js +0 -169
  211. package/dist/shared/console-formatter.d.ts +0 -22
  212. package/dist/shared/console-formatter.js +0 -161
  213. package/dist/shared/constants.d.ts +0 -3
  214. package/dist/shared/constants.js +0 -3
  215. package/dist/shared/errors.d.ts +0 -16
  216. package/dist/shared/errors.js +0 -21
  217. package/dist/shared/esm-utils.d.ts +0 -28
  218. package/dist/shared/esm-utils.js +0 -97
  219. package/dist/shared/global-exposure.d.ts +0 -38
  220. package/dist/shared/global-exposure.js +0 -876
  221. package/dist/shared/in-memory-fs.d.ts +0 -16
  222. package/dist/shared/in-memory-fs.js +0 -115
  223. package/dist/shared/permissions.d.ts +0 -36
  224. package/dist/shared/permissions.js +0 -314
  225. package/dist/shared/require-setup.d.ts +0 -6
  226. package/dist/shared/require-setup.js +0 -9
  227. package/dist/test/block-store-conformance.d.ts +0 -34
  228. package/dist/test/block-store-conformance.js +0 -251
  229. package/dist/test/metadata-store-conformance.d.ts +0 -37
  230. package/dist/test/metadata-store-conformance.js +0 -646
  231. package/dist/test/vfs-conformance.d.ts +0 -65
  232. package/dist/test/vfs-conformance.js +0 -842
  233. package/dist/types.d.ts +0 -98
  234. package/dist/types.js +0 -6
  235. package/dist/vfs/chunked-vfs.d.ts +0 -66
  236. package/dist/vfs/chunked-vfs.js +0 -1290
  237. package/dist/vfs/host-block-store.d.ts +0 -19
  238. package/dist/vfs/host-block-store.js +0 -97
  239. package/dist/vfs/memory-block-store.d.ts +0 -16
  240. package/dist/vfs/memory-block-store.js +0 -45
  241. package/dist/vfs/memory-metadata.d.ts +0 -75
  242. package/dist/vfs/memory-metadata.js +0 -528
  243. package/dist/vfs/sqlite-metadata.d.ts +0 -91
  244. package/dist/vfs/sqlite-metadata.js +0 -582
  245. package/dist/vfs/types.d.ts +0 -210
  246. package/dist/vfs/types.js +0 -8
  247. /package/dist/{runtime-driver.js → generated/CreateVmConfig.js} +0 -0
  248. /package/dist/{shared/api-types.js → generated/FsPermissionRule.js} +0 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * NodeRuntime — ergonomic façade for running guest JavaScript end-to-end.
3
+ *
4
+ * Boots a fully virtualized VM (via the native sidecar) and runs guest Node
5
+ * programs with minimal boilerplate. All of the sidecar spawn, session
6
+ * handshake, VM creation, root filesystem bootstrap, runtime-driver mounting,
7
+ * and lifecycle waiting are hidden behind `NodeRuntime.create()`.
8
+ *
9
+ * ```ts
10
+ * const rt = await NodeRuntime.create();
11
+ * const { stdout, exitCode } = await rt.exec("console.log('hi', 1 + 1)");
12
+ * await rt.dispose();
13
+ * ```
14
+ *
15
+ * Guest code is written to an ESM module inside the VM and executed as
16
+ * `node <file>` through the kernel, so all execution stays inside the kernel
17
+ * isolation boundary — no host escapes, no real Node.js builtins for guest
18
+ * work.
19
+ */
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { createInMemoryFileSystem, createKernel, createNodeRuntime, createWasmVmRuntime, NodeFileSystem, } from "./test-runtime.js";
23
+ /** Repository root, used to locate the bundled WASM command binaries. */
24
+ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
25
+ /**
26
+ * Directory containing the WASM coreutils/shell command binaries that provide
27
+ * the guest `sh` the kernel needs to drive `exec()`. Built from the in-repo
28
+ * Rust command sources.
29
+ */
30
+ const DEFAULT_COMMANDS_DIR = path.join(REPO_ROOT, "registry/native/target/wasm32-wasip1/release/commands");
31
+ /**
32
+ * Secure-by-default permission policy applied when the caller passes no
33
+ * `permissions`. Outward-facing capabilities are denied: there is **no network
34
+ * access** (and no host callbacks) by default — guest code cannot reach the
35
+ * network until you opt in. The filesystem, child-process, process, and env
36
+ * scopes are allowed because they are fully virtualized (the guest only ever
37
+ * sees the VM's in-memory filesystem and kernel-managed processes, never the
38
+ * real host) and are required for the runtime to execute a guest program at
39
+ * all. Tighten or widen any scope by passing your own `permissions`.
40
+ */
41
+ const DEFAULT_PERMISSIONS = {
42
+ fs: "allow",
43
+ childProcess: "allow",
44
+ process: "allow",
45
+ env: "allow",
46
+ network: "deny",
47
+ };
48
+ let nextProgramId = 0;
49
+ /**
50
+ * Ergonomic, batteries-included runtime for executing guest JavaScript.
51
+ *
52
+ * Construct one with {@link NodeRuntime.create}, run programs with
53
+ * {@link NodeRuntime.exec} / {@link NodeRuntime.run}, and release the VM with
54
+ * {@link NodeRuntime.dispose}. A single instance can run many programs; each
55
+ * call executes a fresh guest process.
56
+ */
57
+ export class NodeRuntime {
58
+ kernel;
59
+ constructor(kernel) {
60
+ this.kernel = kernel;
61
+ }
62
+ /**
63
+ * Boot a VM and return a ready-to-use runtime. Spawns the sidecar, opens a
64
+ * session, creates the VM with a bootstrapped root filesystem, mounts the
65
+ * shell and Node runtimes, and waits for the VM to report ready.
66
+ */
67
+ static async create(options = {}) {
68
+ const commandsDir = options.commandsDir ??
69
+ process.env.SECURE_EXEC_WASM_COMMANDS_DIR ??
70
+ DEFAULT_COMMANDS_DIR;
71
+ // Seed caller-provided files into the VM's in-memory filesystem before
72
+ // boot so they are part of the root filesystem snapshot the guest sees
73
+ // (e.g. projected npm packages or fixtures). The host filesystem is
74
+ // never exposed; only these bytes are copied in.
75
+ const filesystem = createInMemoryFileSystem();
76
+ if (options.files) {
77
+ for (const [filePath, content] of Object.entries(options.files)) {
78
+ await filesystem.writeFile(filePath, content);
79
+ }
80
+ }
81
+ // Project host directories into the VM, Docker-style. NodeFileSystem
82
+ // reads lazily through the VFS so large trees never traverse the
83
+ // protocol frame as a single blob.
84
+ const mounts = (options.mounts ?? []).map((mount) => ({
85
+ path: mount.guestPath,
86
+ fs: new NodeFileSystem({ root: mount.hostPath }),
87
+ readOnly: mount.readOnly ?? true,
88
+ }));
89
+ // Grant the `tool` scope when the caller registers tools but does not set
90
+ // their own tool policy, so the registered tools are actually invocable.
91
+ const toolDefaults = options.tools &&
92
+ Object.keys(options.tools).length > 0 &&
93
+ options.permissions?.tool === undefined
94
+ ? { tool: "allow" }
95
+ : {};
96
+ const kernel = createKernel({
97
+ filesystem,
98
+ mounts: mounts.length > 0 ? mounts : undefined,
99
+ // Merge the caller's policy over the secure default so partial
100
+ // opt-ins work: `{ network: "allow" }` enables the network while the
101
+ // execution essentials (fs/childProcess/process/env) stay granted.
102
+ permissions: {
103
+ ...DEFAULT_PERMISSIONS,
104
+ ...toolDefaults,
105
+ ...options.permissions,
106
+ },
107
+ env: options.env,
108
+ cwd: options.cwd,
109
+ loopbackExemptPorts: options.loopbackExemptPorts,
110
+ });
111
+ try {
112
+ // The shell runtime provides `sh` plus coreutils; the Node runtime
113
+ // provides the real V8-backed `node`. `sh` is REQUIRED to spawn any
114
+ // process: the kernel runs every command through a shell, so without
115
+ // `sh` nothing can be spawned, including the guest `node` program we
116
+ // run here and any child the guest spawns via node:child_process.
117
+ await kernel.mount(createWasmVmRuntime({ commandDirs: [commandsDir] }));
118
+ await kernel.mount(createNodeRuntime());
119
+ // Register host tools after the runtimes are mounted so they are
120
+ // installed as guest commands the moment the VM is ready.
121
+ if (options.tools && Object.keys(options.tools).length > 0) {
122
+ await kernel.registerHostTools(options.tools);
123
+ }
124
+ }
125
+ catch (error) {
126
+ await kernel.dispose().catch(() => { });
127
+ throw error;
128
+ }
129
+ return new NodeRuntime(kernel);
130
+ }
131
+ /**
132
+ * Run `code` as a guest Node program and capture its output.
133
+ *
134
+ * The source is written to an ES module inside the VM and executed with
135
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`).
136
+ */
137
+ async exec(code, options = {}) {
138
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
139
+ await this.kernel.writeFile(programPath, code);
140
+ return this.runProgram(programPath, options);
141
+ }
142
+ /**
143
+ * Run an already-written guest program file to completion and capture its
144
+ * output, honoring a caller-supplied `signal` for cancellation.
145
+ *
146
+ * Without a `signal`, this runs the program through the shell (`node <file>`)
147
+ * exactly as before. With a `signal`, it starts the program as a guest
148
+ * process so the run can be cancelled: when the signal aborts, the process is
149
+ * killed inside the VM (the kernel delivers `SIGTERM`) and the call rejects
150
+ * with the signal's abort reason.
151
+ */
152
+ async runProgram(programPath, options) {
153
+ const signal = options.signal;
154
+ if (!signal) {
155
+ const result = await this.kernel.exec(`node ${programPath}`, {
156
+ env: options.env,
157
+ cwd: options.cwd,
158
+ stdin: options.stdin,
159
+ timeout: options.timeout,
160
+ onStdout: options.onStdout,
161
+ onStderr: options.onStderr,
162
+ });
163
+ return toExecResult(result);
164
+ }
165
+ if (signal.aborted) {
166
+ throw toAbortError(signal);
167
+ }
168
+ // A signal was supplied, so run the program as a guest process we can
169
+ // kill: aborting the signal maps to a kernel kill of the underlying
170
+ // process. Aggregate the streamed output ourselves to reproduce the
171
+ // run-to-completion result that the shell path returns.
172
+ const stdoutChunks = [];
173
+ const stderrChunks = [];
174
+ const proc = this.kernel.spawn("node", [programPath], {
175
+ env: options.env,
176
+ cwd: options.cwd,
177
+ onStdout: (chunk) => {
178
+ stdoutChunks.push(chunk);
179
+ options.onStdout?.(chunk);
180
+ },
181
+ onStderr: (chunk) => {
182
+ stderrChunks.push(chunk);
183
+ options.onStderr?.(chunk);
184
+ },
185
+ streamStdin: options.stdin !== undefined,
186
+ });
187
+ if (options.stdin !== undefined) {
188
+ proc.writeStdin(options.stdin);
189
+ proc.closeStdin();
190
+ }
191
+ const onAbort = () => {
192
+ // Deliver SIGTERM to cancel the in-flight run inside the VM.
193
+ proc.kill(toSignalNumber("SIGTERM"));
194
+ };
195
+ signal.addEventListener("abort", onAbort, { once: true });
196
+ let timer;
197
+ if (options.timeout !== undefined) {
198
+ timer = setTimeout(() => {
199
+ proc.kill(toSignalNumber("SIGKILL"));
200
+ }, options.timeout);
201
+ }
202
+ try {
203
+ const exitCode = await proc.wait();
204
+ if (signal.aborted) {
205
+ throw toAbortError(signal);
206
+ }
207
+ return {
208
+ stdout: decodeChunks(stdoutChunks),
209
+ stderr: decodeChunks(stderrChunks),
210
+ exitCode,
211
+ };
212
+ }
213
+ finally {
214
+ if (timer !== undefined) {
215
+ clearTimeout(timer);
216
+ }
217
+ signal.removeEventListener("abort", onAbort);
218
+ }
219
+ }
220
+ /**
221
+ * Start `code` as a long-running guest Node program and return a live handle
222
+ * to it, without waiting for it to finish.
223
+ *
224
+ * The source is written to an ES module inside the VM and started with
225
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`). The
226
+ * returned {@link NodeRuntimeProcess} lets you stream output, write to stdin,
227
+ * signal or kill the process, and await its exit. Pass `onStdout`/`onStderr`
228
+ * to receive output chunks as they are produced.
229
+ *
230
+ * Use this for guests that do not run to completion, such as a dev server you
231
+ * later drive with {@link NodeRuntime.fetch}:
232
+ *
233
+ * ```ts
234
+ * const server = await rt.spawn(`
235
+ * import http from "node:http";
236
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
237
+ * `);
238
+ * const res = await rt.fetch(3000, { path: "/" });
239
+ * server.kill();
240
+ * await server.wait();
241
+ * ```
242
+ */
243
+ async spawn(code, options = {}) {
244
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
245
+ await this.kernel.writeFile(programPath, code);
246
+ const proc = this.kernel.spawn("node", [programPath], {
247
+ env: options.env,
248
+ cwd: options.cwd,
249
+ onStdout: options.onStdout,
250
+ onStderr: options.onStderr,
251
+ // Keep stdin open so callers can stream input via writeStdin and signal
252
+ // end-of-input with closeStdin.
253
+ streamStdin: true,
254
+ });
255
+ return {
256
+ pid: proc.pid,
257
+ writeStdin(data) {
258
+ proc.writeStdin(data);
259
+ },
260
+ closeStdin() {
261
+ proc.closeStdin();
262
+ },
263
+ kill(signal) {
264
+ proc.kill(toSignalNumber(signal));
265
+ },
266
+ wait() {
267
+ return proc.wait();
268
+ },
269
+ get exitCode() {
270
+ return proc.exitCode;
271
+ },
272
+ };
273
+ }
274
+ /**
275
+ * Run `code` and return the JSON-serializable value it produces.
276
+ *
277
+ * The guest exposes a `__return(value)` function; call it with a
278
+ * JSON-serializable value and that value is decoded on the host as
279
+ * `result.value`. If `__return` is never called the value is `undefined`.
280
+ * stdout/stderr/exitCode are still captured.
281
+ */
282
+ async run(code, options = {}) {
283
+ const id = nextProgramId++;
284
+ const resultPath = `/tmp/secure-exec-result-${id}.json`;
285
+ const programPath = `/tmp/secure-exec-program-${id}.mjs`;
286
+ // Inject the __return helper as a module-level preamble, then the user
287
+ // code at module top level. Import declarations (preamble's and the
288
+ // user's) are hoisted, so __return is defined before the user's
289
+ // executable code runs — and the user keeps full ESM semantics
290
+ // (top-level `import` and top-level `await` both work). Do NOT wrap the
291
+ // user code in an IIFE: that would push their top-level `import`
292
+ // statements inside a function and make them a SyntaxError.
293
+ const wrapped = [
294
+ `import { writeFileSync as __writeFileSync } from "node:fs";`,
295
+ `globalThis.__return = (value) => {`,
296
+ ` __writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(value === undefined ? null : value));`,
297
+ `};`,
298
+ code,
299
+ ].join("\n");
300
+ await this.kernel.writeFile(programPath, wrapped);
301
+ const exec = await this.runProgram(programPath, options);
302
+ let value;
303
+ if (exec.exitCode === 0) {
304
+ try {
305
+ const bytes = await this.kernel.readFile(resultPath);
306
+ value = JSON.parse(new TextDecoder().decode(bytes));
307
+ }
308
+ catch {
309
+ // No __return() call (or unreadable result): leave value undefined.
310
+ }
311
+ }
312
+ return { ...exec, value };
313
+ }
314
+ /**
315
+ * Drive an HTTP request to a guest HTTP server listening inside the VM and
316
+ * read the response back on the host.
317
+ *
318
+ * Point this at a port a guest program is serving, for example a dev server
319
+ * started with {@link NodeRuntime.exec}. The
320
+ * request and response never leave the VM: the connection is made to the
321
+ * guest's loopback listener through the kernel socket table, so this works
322
+ * even when guest network egress is denied.
323
+ *
324
+ * ```ts
325
+ * const res = await rt.fetch(3000, { path: "/health" });
326
+ * console.log(res.status, res.body);
327
+ * ```
328
+ */
329
+ async fetch(port, input) {
330
+ const body = input.body === undefined
331
+ ? undefined
332
+ : typeof input.body === "string"
333
+ ? input.body
334
+ : new TextDecoder().decode(input.body);
335
+ const responseJson = await this.kernel.vmFetch({
336
+ port,
337
+ method: input.method ?? "GET",
338
+ path: input.path,
339
+ headersJson: JSON.stringify(input.headers ?? {}),
340
+ body,
341
+ });
342
+ return parseFetchResponse(responseJson);
343
+ }
344
+ /**
345
+ * Look up a guest TCP listener once and return it, or `null` when nothing is
346
+ * listening yet.
347
+ *
348
+ * This is the immediate, non-blocking check behind
349
+ * {@link NodeRuntime.waitForListener}: it asks the kernel socket table
350
+ * whether a guest process is accepting connections on the requested `port`
351
+ * (optionally narrowed by `host`/`path`) and returns the match, or `null` if
352
+ * none is up. Use {@link NodeRuntime.waitForListener} when you want to block
353
+ * until one appears.
354
+ *
355
+ * ```ts
356
+ * const listener = rt.findListener({ port: 3000 });
357
+ * if (listener) console.log("up on pid", listener.processId);
358
+ * ```
359
+ */
360
+ findListener(query) {
361
+ const match = this.kernel.socketTable.findListener({
362
+ port: query.port,
363
+ ...(query.host !== undefined ? { host: query.host } : {}),
364
+ ...(query.path !== undefined ? { path: query.path } : {}),
365
+ });
366
+ return match ?? null;
367
+ }
368
+ /**
369
+ * Block until a guest TCP listener is accepting connections on the requested
370
+ * `port` (optionally narrowed by `host`/`path`), then resolve with it.
371
+ *
372
+ * This is the companion to {@link NodeRuntime.spawn} and
373
+ * {@link NodeRuntime.fetch} for dev-server scenarios: start a server, wait
374
+ * until it is actually listening, then drive requests into it. The kernel
375
+ * socket table is polled until a matching listener appears or the wait is
376
+ * cut short. If `timeoutMs` elapses (default 10000) or the supplied `signal`
377
+ * aborts first, the returned promise rejects.
378
+ *
379
+ * ```ts
380
+ * const server = await rt.spawn(`
381
+ * import http from "node:http";
382
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
383
+ * `);
384
+ * const listener = await rt.waitForListener({ port: 3000 });
385
+ * const res = await rt.fetch(listener.port ?? 3000, { path: "/" });
386
+ * server.kill();
387
+ * await server.wait();
388
+ * ```
389
+ */
390
+ async waitForListener(query, options = {}) {
391
+ const timeoutMs = options.timeoutMs ?? 10_000;
392
+ const pollIntervalMs = options.pollIntervalMs ?? 50;
393
+ const signal = options.signal;
394
+ const deadline = Date.now() + timeoutMs;
395
+ for (;;) {
396
+ if (signal?.aborted) {
397
+ throw toAbortError(signal);
398
+ }
399
+ const match = this.findListener(query);
400
+ if (match) {
401
+ return match;
402
+ }
403
+ if (Date.now() >= deadline) {
404
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for a listener on port ${query.port}`);
405
+ }
406
+ await delayUntil(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
407
+ }
408
+ }
409
+ /**
410
+ * Register host-side tools the guest can invoke as shell commands, after the
411
+ * VM is already running. Each entry becomes a named guest command; when the
412
+ * guest runs it, the invocation round-trips back to the host and runs the
413
+ * tool's `handler`, whose return value is delivered back to the guest. This
414
+ * is the same capability as the `tools` create option, exposed for adding
415
+ * tools to a live runtime. See `tools` on {@link NodeRuntime.create} for the
416
+ * invocation shape and permission behavior.
417
+ *
418
+ * When registering tools this way, make sure the `tool` permission scope is
419
+ * granted (for example `permissions: { tool: "allow" }` on
420
+ * {@link NodeRuntime.create}) so the tools are invocable.
421
+ */
422
+ async registerTools(tools) {
423
+ await this.kernel.registerHostTools(tools);
424
+ }
425
+ /**
426
+ * Write a file into the VM's virtual filesystem, creating parent
427
+ * directories as needed. Use this to project assets or npm packages into
428
+ * the sandbox after boot; the host filesystem is never touched.
429
+ */
430
+ async writeFile(filePath, content) {
431
+ await this.kernel.writeFile(filePath, content);
432
+ }
433
+ /** Read a file from the VM's virtual filesystem as raw bytes. */
434
+ async readFile(filePath) {
435
+ return this.kernel.readFile(filePath);
436
+ }
437
+ /** Tear down the VM and release the sidecar. */
438
+ async dispose() {
439
+ await this.kernel.dispose();
440
+ }
441
+ }
442
+ /**
443
+ * Common POSIX signal numbers, used to translate a signal name passed to
444
+ * {@link NodeRuntimeProcess.kill} into the numeric signal the kernel expects.
445
+ */
446
+ const SIGNAL_NUMBERS = {
447
+ SIGHUP: 1,
448
+ SIGINT: 2,
449
+ SIGQUIT: 3,
450
+ SIGKILL: 9,
451
+ SIGUSR1: 10,
452
+ SIGUSR2: 12,
453
+ SIGTERM: 15,
454
+ SIGSTOP: 19,
455
+ SIGCONT: 18,
456
+ };
457
+ /**
458
+ * Normalize a signal passed to {@link NodeRuntimeProcess.kill} into the numeric
459
+ * signal the kernel expects. Accepts a signal name (e.g. `"SIGKILL"`) or a raw
460
+ * number; defaults to `SIGTERM` when omitted.
461
+ */
462
+ function toSignalNumber(signal) {
463
+ if (signal === undefined) {
464
+ return SIGNAL_NUMBERS.SIGTERM;
465
+ }
466
+ if (typeof signal === "number") {
467
+ return signal;
468
+ }
469
+ const resolved = SIGNAL_NUMBERS[signal];
470
+ if (resolved === undefined) {
471
+ throw new Error(`Unknown signal: ${signal}`);
472
+ }
473
+ return resolved;
474
+ }
475
+ /**
476
+ * Build the error a {@link NodeRuntime.waitForListener} wait rejects with when
477
+ * its abort signal fires, preferring the signal's own `reason` when present.
478
+ */
479
+ function toAbortError(signal) {
480
+ const reason = signal.reason;
481
+ if (reason instanceof Error) {
482
+ return reason;
483
+ }
484
+ const error = new Error("The listener wait was aborted");
485
+ error.name = "AbortError";
486
+ return error;
487
+ }
488
+ /**
489
+ * Resolve after `ms` milliseconds, or reject early if `signal` aborts. Used to
490
+ * pace the polling loop in {@link NodeRuntime.waitForListener} without blocking
491
+ * past an abort.
492
+ */
493
+ function delayUntil(ms, signal) {
494
+ return new Promise((resolve, reject) => {
495
+ if (signal?.aborted) {
496
+ reject(toAbortError(signal));
497
+ return;
498
+ }
499
+ const timer = setTimeout(() => {
500
+ signal?.removeEventListener("abort", onAbort);
501
+ resolve();
502
+ }, ms);
503
+ const onAbort = () => {
504
+ clearTimeout(timer);
505
+ reject(toAbortError(signal));
506
+ };
507
+ signal?.addEventListener("abort", onAbort, { once: true });
508
+ });
509
+ }
510
+ /**
511
+ * Concatenate streamed stdout/stderr chunks and decode them as UTF-8 text,
512
+ * reproducing the aggregated `stdout`/`stderr` strings the shell-backed
513
+ * {@link NodeRuntime.exec} path returns when a run is driven as a process for
514
+ * cancellation support.
515
+ */
516
+ function decodeChunks(chunks) {
517
+ if (chunks.length === 0) {
518
+ return "";
519
+ }
520
+ let total = 0;
521
+ for (const chunk of chunks) {
522
+ total += chunk.length;
523
+ }
524
+ const merged = new Uint8Array(total);
525
+ let offset = 0;
526
+ for (const chunk of chunks) {
527
+ merged.set(chunk, offset);
528
+ offset += chunk.length;
529
+ }
530
+ return new TextDecoder().decode(merged);
531
+ }
532
+ function toExecResult(result) {
533
+ return {
534
+ stdout: result.stdout,
535
+ stderr: result.stderr,
536
+ exitCode: result.exitCode,
537
+ };
538
+ }
539
+ /**
540
+ * Decode the raw JSON the kernel returns for a VM HTTP request into a
541
+ * structured response. The wire shape carries `status`, an optional
542
+ * `statusText`, `headers` (either an array of `[name, value]` pairs or an
543
+ * object), and a `body` that is base64-encoded when `bodyEncoding` is
544
+ * `"base64"`.
545
+ */
546
+ function parseFetchResponse(responseJson) {
547
+ const parsed = JSON.parse(responseJson);
548
+ const headers = {};
549
+ if (Array.isArray(parsed.headers)) {
550
+ for (const [name, value] of parsed.headers) {
551
+ headers[name.toLowerCase()] = value;
552
+ }
553
+ }
554
+ else if (parsed.headers) {
555
+ for (const [name, value] of Object.entries(parsed.headers)) {
556
+ headers[name.toLowerCase()] = value;
557
+ }
558
+ }
559
+ let body = parsed.body ?? "";
560
+ if (parsed.bodyEncoding === "base64" && body.length > 0) {
561
+ body = new TextDecoder().decode(Uint8Array.from(globalThis.atob(body), (char) => char.charCodeAt(0)));
562
+ }
563
+ return {
564
+ status: parsed.status ?? 0,
565
+ statusText: parsed.statusText ?? "",
566
+ headers,
567
+ body,
568
+ };
569
+ }
@@ -0,0 +1 @@
1
+ export declare function bigIntToSafeNumber(value: bigint, context: string): number;
@@ -0,0 +1,8 @@
1
+ export function bigIntToSafeNumber(value, context) {
2
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
3
+ const min = BigInt(Number.MIN_SAFE_INTEGER);
4
+ if (value > max || value < min) {
5
+ throw new Error(`${context} exceeds JavaScript safe integer range`);
6
+ }
7
+ return Number(value);
8
+ }
@@ -0,0 +1,18 @@
1
+ import * as protocol from "./generated-protocol.js";
2
+ export type LiveOwnershipScope = {
3
+ scope: "connection";
4
+ connection_id: string;
5
+ } | {
6
+ scope: "session";
7
+ connection_id: string;
8
+ session_id: string;
9
+ } | {
10
+ scope: "vm";
11
+ connection_id: string;
12
+ session_id: string;
13
+ vm_id: string;
14
+ };
15
+ export declare function ownershipSelectorKey(ownership: LiveOwnershipScope): string;
16
+ export declare function ownershipMatchesSelector(selector: LiveOwnershipScope | undefined, ownership: LiveOwnershipScope): boolean;
17
+ export declare function toGeneratedOwnershipScope(ownership: LiveOwnershipScope): protocol.OwnershipScope;
18
+ export declare function fromGeneratedOwnershipScope(ownership: protocol.OwnershipScope): LiveOwnershipScope;
@@ -0,0 +1,77 @@
1
+ export function ownershipSelectorKey(ownership) {
2
+ switch (ownership.scope) {
3
+ case "connection":
4
+ return `connection:${ownership.connection_id}`;
5
+ case "session":
6
+ return `session:${ownership.connection_id}:${ownership.session_id}`;
7
+ case "vm":
8
+ return `vm:${ownership.connection_id}:${ownership.session_id}:${ownership.vm_id}`;
9
+ }
10
+ }
11
+ export function ownershipMatchesSelector(selector, ownership) {
12
+ if (!selector) {
13
+ return true;
14
+ }
15
+ switch (selector.scope) {
16
+ case "connection":
17
+ return (ownership.scope === "connection" &&
18
+ selector.connection_id === ownership.connection_id);
19
+ case "session":
20
+ return (ownership.scope === "session" &&
21
+ selector.connection_id === ownership.connection_id &&
22
+ selector.session_id === ownership.session_id);
23
+ case "vm":
24
+ return (ownership.scope === "vm" &&
25
+ selector.connection_id === ownership.connection_id &&
26
+ selector.session_id === ownership.session_id &&
27
+ selector.vm_id === ownership.vm_id);
28
+ }
29
+ }
30
+ export function toGeneratedOwnershipScope(ownership) {
31
+ switch (ownership.scope) {
32
+ case "connection":
33
+ return {
34
+ tag: "ConnectionOwnership",
35
+ val: { connectionId: ownership.connection_id },
36
+ };
37
+ case "session":
38
+ return {
39
+ tag: "SessionOwnership",
40
+ val: {
41
+ connectionId: ownership.connection_id,
42
+ sessionId: ownership.session_id,
43
+ },
44
+ };
45
+ case "vm":
46
+ return {
47
+ tag: "VmOwnership",
48
+ val: {
49
+ connectionId: ownership.connection_id,
50
+ sessionId: ownership.session_id,
51
+ vmId: ownership.vm_id,
52
+ },
53
+ };
54
+ }
55
+ }
56
+ export function fromGeneratedOwnershipScope(ownership) {
57
+ switch (ownership.tag) {
58
+ case "ConnectionOwnership":
59
+ return {
60
+ scope: "connection",
61
+ connection_id: ownership.val.connectionId,
62
+ };
63
+ case "SessionOwnership":
64
+ return {
65
+ scope: "session",
66
+ connection_id: ownership.val.connectionId,
67
+ session_id: ownership.val.sessionId,
68
+ };
69
+ case "VmOwnership":
70
+ return {
71
+ scope: "vm",
72
+ connection_id: ownership.val.connectionId,
73
+ session_id: ownership.val.sessionId,
74
+ vm_id: ownership.val.vmId,
75
+ };
76
+ }
77
+ }