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

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 +490 -0
  99. package/dist/node-runtime.js +585 -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,585 @@
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
+ /** Guest path a `nodeModules` mount is projected at by default. */
49
+ const DEFAULT_NODE_MODULES_GUEST_PATH = "/tmp/node_modules";
50
+ let nextProgramId = 0;
51
+ /**
52
+ * Ergonomic, batteries-included runtime for executing guest JavaScript.
53
+ *
54
+ * Construct one with {@link NodeRuntime.create}, run programs with
55
+ * {@link NodeRuntime.exec} / {@link NodeRuntime.run}, and release the VM with
56
+ * {@link NodeRuntime.dispose}. A single instance can run many programs; each
57
+ * call executes a fresh guest process.
58
+ */
59
+ export class NodeRuntime {
60
+ kernel;
61
+ constructor(kernel) {
62
+ this.kernel = kernel;
63
+ }
64
+ /**
65
+ * Boot a VM and return a ready-to-use runtime. Spawns the sidecar, opens a
66
+ * session, creates the VM with a bootstrapped root filesystem, mounts the
67
+ * shell and Node runtimes, and waits for the VM to report ready.
68
+ */
69
+ static async create(options = {}) {
70
+ const commandsDir = options.commandsDir ??
71
+ process.env.SECURE_EXEC_WASM_COMMANDS_DIR ??
72
+ DEFAULT_COMMANDS_DIR;
73
+ // Seed caller-provided files into the VM's in-memory filesystem before
74
+ // boot so they are part of the root filesystem snapshot the guest sees
75
+ // (e.g. projected npm packages or fixtures). The host filesystem is
76
+ // never exposed; only these bytes are copied in.
77
+ const filesystem = createInMemoryFileSystem();
78
+ if (options.files) {
79
+ for (const [filePath, content] of Object.entries(options.files)) {
80
+ await filesystem.writeFile(filePath, content);
81
+ }
82
+ }
83
+ // Project host directories into the VM, Docker-style. NodeFileSystem
84
+ // reads lazily through the VFS so large trees never traverse the
85
+ // protocol frame as a single blob.
86
+ const hostMounts = [...(options.mounts ?? [])];
87
+ // The `nodeModules` helper is sugar over a single host directory mount:
88
+ // project the whole host `node_modules` at a guest `node_modules` on the
89
+ // resolution path so any package inside resolves like real Node would.
90
+ if (options.nodeModules !== undefined) {
91
+ const nodeModules = typeof options.nodeModules === "string"
92
+ ? { hostPath: options.nodeModules }
93
+ : options.nodeModules;
94
+ hostMounts.push({
95
+ guestPath: nodeModules.guestPath ?? DEFAULT_NODE_MODULES_GUEST_PATH,
96
+ hostPath: nodeModules.hostPath,
97
+ readOnly: true,
98
+ });
99
+ }
100
+ const mounts = hostMounts.map((mount) => ({
101
+ path: mount.guestPath,
102
+ fs: new NodeFileSystem({ root: mount.hostPath }),
103
+ readOnly: mount.readOnly ?? true,
104
+ }));
105
+ // Grant the `tool` scope when the caller registers tools but does not set
106
+ // their own tool policy, so the registered tools are actually invocable.
107
+ const toolDefaults = options.tools &&
108
+ Object.keys(options.tools).length > 0 &&
109
+ options.permissions?.tool === undefined
110
+ ? { tool: "allow" }
111
+ : {};
112
+ const kernel = createKernel({
113
+ filesystem,
114
+ mounts: mounts.length > 0 ? mounts : undefined,
115
+ // Merge the caller's policy over the secure default so partial
116
+ // opt-ins work: `{ network: "allow" }` enables the network while the
117
+ // execution essentials (fs/childProcess/process/env) stay granted.
118
+ permissions: {
119
+ ...DEFAULT_PERMISSIONS,
120
+ ...toolDefaults,
121
+ ...options.permissions,
122
+ },
123
+ env: options.env,
124
+ cwd: options.cwd,
125
+ loopbackExemptPorts: options.loopbackExemptPorts,
126
+ });
127
+ try {
128
+ // The shell runtime provides `sh` plus coreutils; the Node runtime
129
+ // provides the real V8-backed `node`. `sh` is REQUIRED to spawn any
130
+ // process: the kernel runs every command through a shell, so without
131
+ // `sh` nothing can be spawned, including the guest `node` program we
132
+ // run here and any child the guest spawns via node:child_process.
133
+ await kernel.mount(createWasmVmRuntime({ commandDirs: [commandsDir] }));
134
+ await kernel.mount(createNodeRuntime());
135
+ // Register host tools after the runtimes are mounted so they are
136
+ // installed as guest commands the moment the VM is ready.
137
+ if (options.tools && Object.keys(options.tools).length > 0) {
138
+ await kernel.registerHostTools(options.tools);
139
+ }
140
+ }
141
+ catch (error) {
142
+ await kernel.dispose().catch(() => { });
143
+ throw error;
144
+ }
145
+ return new NodeRuntime(kernel);
146
+ }
147
+ /**
148
+ * Run `code` as a guest Node program and capture its output.
149
+ *
150
+ * The source is written to an ES module inside the VM and executed with
151
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`).
152
+ */
153
+ async exec(code, options = {}) {
154
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
155
+ await this.kernel.writeFile(programPath, code);
156
+ return this.runProgram(programPath, options);
157
+ }
158
+ /**
159
+ * Run an already-written guest program file to completion and capture its
160
+ * output, honoring a caller-supplied `signal` for cancellation.
161
+ *
162
+ * Without a `signal`, this runs the program through the shell (`node <file>`)
163
+ * exactly as before. With a `signal`, it starts the program as a guest
164
+ * process so the run can be cancelled: when the signal aborts, the process is
165
+ * killed inside the VM (the kernel delivers `SIGTERM`) and the call rejects
166
+ * with the signal's abort reason.
167
+ */
168
+ async runProgram(programPath, options) {
169
+ const signal = options.signal;
170
+ if (!signal) {
171
+ const result = await this.kernel.exec(`node ${programPath}`, {
172
+ env: options.env,
173
+ cwd: options.cwd,
174
+ stdin: options.stdin,
175
+ timeout: options.timeout,
176
+ onStdout: options.onStdout,
177
+ onStderr: options.onStderr,
178
+ });
179
+ return toExecResult(result);
180
+ }
181
+ if (signal.aborted) {
182
+ throw toAbortError(signal);
183
+ }
184
+ // A signal was supplied, so run the program as a guest process we can
185
+ // kill: aborting the signal maps to a kernel kill of the underlying
186
+ // process. Aggregate the streamed output ourselves to reproduce the
187
+ // run-to-completion result that the shell path returns.
188
+ const stdoutChunks = [];
189
+ const stderrChunks = [];
190
+ const proc = this.kernel.spawn("node", [programPath], {
191
+ env: options.env,
192
+ cwd: options.cwd,
193
+ onStdout: (chunk) => {
194
+ stdoutChunks.push(chunk);
195
+ options.onStdout?.(chunk);
196
+ },
197
+ onStderr: (chunk) => {
198
+ stderrChunks.push(chunk);
199
+ options.onStderr?.(chunk);
200
+ },
201
+ streamStdin: options.stdin !== undefined,
202
+ });
203
+ if (options.stdin !== undefined) {
204
+ proc.writeStdin(options.stdin);
205
+ proc.closeStdin();
206
+ }
207
+ const onAbort = () => {
208
+ // Deliver SIGTERM to cancel the in-flight run inside the VM.
209
+ proc.kill(toSignalNumber("SIGTERM"));
210
+ };
211
+ signal.addEventListener("abort", onAbort, { once: true });
212
+ let timer;
213
+ if (options.timeout !== undefined) {
214
+ timer = setTimeout(() => {
215
+ proc.kill(toSignalNumber("SIGKILL"));
216
+ }, options.timeout);
217
+ }
218
+ try {
219
+ const exitCode = await proc.wait();
220
+ if (signal.aborted) {
221
+ throw toAbortError(signal);
222
+ }
223
+ return {
224
+ stdout: decodeChunks(stdoutChunks),
225
+ stderr: decodeChunks(stderrChunks),
226
+ exitCode,
227
+ };
228
+ }
229
+ finally {
230
+ if (timer !== undefined) {
231
+ clearTimeout(timer);
232
+ }
233
+ signal.removeEventListener("abort", onAbort);
234
+ }
235
+ }
236
+ /**
237
+ * Start `code` as a long-running guest Node program and return a live handle
238
+ * to it, without waiting for it to finish.
239
+ *
240
+ * The source is written to an ES module inside the VM and started with
241
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`). The
242
+ * returned {@link NodeRuntimeProcess} lets you stream output, write to stdin,
243
+ * signal or kill the process, and await its exit. Pass `onStdout`/`onStderr`
244
+ * to receive output chunks as they are produced.
245
+ *
246
+ * Use this for guests that do not run to completion, such as a dev server you
247
+ * later drive with {@link NodeRuntime.fetch}:
248
+ *
249
+ * ```ts
250
+ * const server = await rt.spawn(`
251
+ * import http from "node:http";
252
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
253
+ * `);
254
+ * const res = await rt.fetch(3000, { path: "/" });
255
+ * server.kill();
256
+ * await server.wait();
257
+ * ```
258
+ */
259
+ async spawn(code, options = {}) {
260
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
261
+ await this.kernel.writeFile(programPath, code);
262
+ const proc = this.kernel.spawn("node", [programPath], {
263
+ env: options.env,
264
+ cwd: options.cwd,
265
+ onStdout: options.onStdout,
266
+ onStderr: options.onStderr,
267
+ // Keep stdin open so callers can stream input via writeStdin and signal
268
+ // end-of-input with closeStdin.
269
+ streamStdin: true,
270
+ });
271
+ return {
272
+ pid: proc.pid,
273
+ writeStdin(data) {
274
+ proc.writeStdin(data);
275
+ },
276
+ closeStdin() {
277
+ proc.closeStdin();
278
+ },
279
+ kill(signal) {
280
+ proc.kill(toSignalNumber(signal));
281
+ },
282
+ wait() {
283
+ return proc.wait();
284
+ },
285
+ get exitCode() {
286
+ return proc.exitCode;
287
+ },
288
+ };
289
+ }
290
+ /**
291
+ * Run `code` and return the JSON-serializable value it produces.
292
+ *
293
+ * The guest exposes a `__return(value)` function; call it with a
294
+ * JSON-serializable value and that value is decoded on the host as
295
+ * `result.value`. If `__return` is never called the value is `undefined`.
296
+ * stdout/stderr/exitCode are still captured.
297
+ */
298
+ async run(code, options = {}) {
299
+ const id = nextProgramId++;
300
+ const resultPath = `/tmp/secure-exec-result-${id}.json`;
301
+ const programPath = `/tmp/secure-exec-program-${id}.mjs`;
302
+ // Inject the __return helper as a module-level preamble, then the user
303
+ // code at module top level. Import declarations (preamble's and the
304
+ // user's) are hoisted, so __return is defined before the user's
305
+ // executable code runs — and the user keeps full ESM semantics
306
+ // (top-level `import` and top-level `await` both work). Do NOT wrap the
307
+ // user code in an IIFE: that would push their top-level `import`
308
+ // statements inside a function and make them a SyntaxError.
309
+ const wrapped = [
310
+ `import { writeFileSync as __writeFileSync } from "node:fs";`,
311
+ `globalThis.__return = (value) => {`,
312
+ ` __writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(value === undefined ? null : value));`,
313
+ `};`,
314
+ code,
315
+ ].join("\n");
316
+ await this.kernel.writeFile(programPath, wrapped);
317
+ const exec = await this.runProgram(programPath, options);
318
+ let value;
319
+ if (exec.exitCode === 0) {
320
+ try {
321
+ const bytes = await this.kernel.readFile(resultPath);
322
+ value = JSON.parse(new TextDecoder().decode(bytes));
323
+ }
324
+ catch {
325
+ // No __return() call (or unreadable result): leave value undefined.
326
+ }
327
+ }
328
+ return { ...exec, value };
329
+ }
330
+ /**
331
+ * Drive an HTTP request to a guest HTTP server listening inside the VM and
332
+ * read the response back on the host.
333
+ *
334
+ * Point this at a port a guest program is serving, for example a dev server
335
+ * started with {@link NodeRuntime.exec}. The
336
+ * request and response never leave the VM: the connection is made to the
337
+ * guest's loopback listener through the kernel socket table, so this works
338
+ * even when guest network egress is denied.
339
+ *
340
+ * ```ts
341
+ * const res = await rt.fetch(3000, { path: "/health" });
342
+ * console.log(res.status, res.body);
343
+ * ```
344
+ */
345
+ async fetch(port, input) {
346
+ const body = input.body === undefined
347
+ ? undefined
348
+ : typeof input.body === "string"
349
+ ? input.body
350
+ : new TextDecoder().decode(input.body);
351
+ const responseJson = await this.kernel.vmFetch({
352
+ port,
353
+ method: input.method ?? "GET",
354
+ path: input.path,
355
+ headersJson: JSON.stringify(input.headers ?? {}),
356
+ body,
357
+ });
358
+ return parseFetchResponse(responseJson);
359
+ }
360
+ /**
361
+ * Look up a guest TCP listener once and return it, or `null` when nothing is
362
+ * listening yet.
363
+ *
364
+ * This is the immediate, non-blocking check behind
365
+ * {@link NodeRuntime.waitForListener}: it asks the kernel socket table
366
+ * whether a guest process is accepting connections on the requested `port`
367
+ * (optionally narrowed by `host`/`path`) and returns the match, or `null` if
368
+ * none is up. Use {@link NodeRuntime.waitForListener} when you want to block
369
+ * until one appears.
370
+ *
371
+ * ```ts
372
+ * const listener = rt.findListener({ port: 3000 });
373
+ * if (listener) console.log("up on pid", listener.processId);
374
+ * ```
375
+ */
376
+ findListener(query) {
377
+ const match = this.kernel.socketTable.findListener({
378
+ port: query.port,
379
+ ...(query.host !== undefined ? { host: query.host } : {}),
380
+ ...(query.path !== undefined ? { path: query.path } : {}),
381
+ });
382
+ return match ?? null;
383
+ }
384
+ /**
385
+ * Block until a guest TCP listener is accepting connections on the requested
386
+ * `port` (optionally narrowed by `host`/`path`), then resolve with it.
387
+ *
388
+ * This is the companion to {@link NodeRuntime.spawn} and
389
+ * {@link NodeRuntime.fetch} for dev-server scenarios: start a server, wait
390
+ * until it is actually listening, then drive requests into it. The kernel
391
+ * socket table is polled until a matching listener appears or the wait is
392
+ * cut short. If `timeoutMs` elapses (default 10000) or the supplied `signal`
393
+ * aborts first, the returned promise rejects.
394
+ *
395
+ * ```ts
396
+ * const server = await rt.spawn(`
397
+ * import http from "node:http";
398
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
399
+ * `);
400
+ * const listener = await rt.waitForListener({ port: 3000 });
401
+ * const res = await rt.fetch(listener.port ?? 3000, { path: "/" });
402
+ * server.kill();
403
+ * await server.wait();
404
+ * ```
405
+ */
406
+ async waitForListener(query, options = {}) {
407
+ const timeoutMs = options.timeoutMs ?? 10_000;
408
+ const pollIntervalMs = options.pollIntervalMs ?? 50;
409
+ const signal = options.signal;
410
+ const deadline = Date.now() + timeoutMs;
411
+ for (;;) {
412
+ if (signal?.aborted) {
413
+ throw toAbortError(signal);
414
+ }
415
+ const match = this.findListener(query);
416
+ if (match) {
417
+ return match;
418
+ }
419
+ if (Date.now() >= deadline) {
420
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for a listener on port ${query.port}`);
421
+ }
422
+ await delayUntil(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
423
+ }
424
+ }
425
+ /**
426
+ * Register host-side tools the guest can invoke as shell commands, after the
427
+ * VM is already running. Each entry becomes a named guest command; when the
428
+ * guest runs it, the invocation round-trips back to the host and runs the
429
+ * tool's `handler`, whose return value is delivered back to the guest. This
430
+ * is the same capability as the `tools` create option, exposed for adding
431
+ * tools to a live runtime. See `tools` on {@link NodeRuntime.create} for the
432
+ * invocation shape and permission behavior.
433
+ *
434
+ * When registering tools this way, make sure the `tool` permission scope is
435
+ * granted (for example `permissions: { tool: "allow" }` on
436
+ * {@link NodeRuntime.create}) so the tools are invocable.
437
+ */
438
+ async registerTools(tools) {
439
+ await this.kernel.registerHostTools(tools);
440
+ }
441
+ /**
442
+ * Write a file into the VM's virtual filesystem, creating parent
443
+ * directories as needed. Use this to project assets or npm packages into
444
+ * the sandbox after boot; the host filesystem is never touched.
445
+ */
446
+ async writeFile(filePath, content) {
447
+ await this.kernel.writeFile(filePath, content);
448
+ }
449
+ /** Read a file from the VM's virtual filesystem as raw bytes. */
450
+ async readFile(filePath) {
451
+ return this.kernel.readFile(filePath);
452
+ }
453
+ /** Tear down the VM and release the sidecar. */
454
+ async dispose() {
455
+ await this.kernel.dispose();
456
+ }
457
+ }
458
+ /**
459
+ * Common POSIX signal numbers, used to translate a signal name passed to
460
+ * {@link NodeRuntimeProcess.kill} into the numeric signal the kernel expects.
461
+ */
462
+ const SIGNAL_NUMBERS = {
463
+ SIGHUP: 1,
464
+ SIGINT: 2,
465
+ SIGQUIT: 3,
466
+ SIGKILL: 9,
467
+ SIGUSR1: 10,
468
+ SIGUSR2: 12,
469
+ SIGTERM: 15,
470
+ SIGSTOP: 19,
471
+ SIGCONT: 18,
472
+ };
473
+ /**
474
+ * Normalize a signal passed to {@link NodeRuntimeProcess.kill} into the numeric
475
+ * signal the kernel expects. Accepts a signal name (e.g. `"SIGKILL"`) or a raw
476
+ * number; defaults to `SIGTERM` when omitted.
477
+ */
478
+ function toSignalNumber(signal) {
479
+ if (signal === undefined) {
480
+ return SIGNAL_NUMBERS.SIGTERM;
481
+ }
482
+ if (typeof signal === "number") {
483
+ return signal;
484
+ }
485
+ const resolved = SIGNAL_NUMBERS[signal];
486
+ if (resolved === undefined) {
487
+ throw new Error(`Unknown signal: ${signal}`);
488
+ }
489
+ return resolved;
490
+ }
491
+ /**
492
+ * Build the error a {@link NodeRuntime.waitForListener} wait rejects with when
493
+ * its abort signal fires, preferring the signal's own `reason` when present.
494
+ */
495
+ function toAbortError(signal) {
496
+ const reason = signal.reason;
497
+ if (reason instanceof Error) {
498
+ return reason;
499
+ }
500
+ const error = new Error("The listener wait was aborted");
501
+ error.name = "AbortError";
502
+ return error;
503
+ }
504
+ /**
505
+ * Resolve after `ms` milliseconds, or reject early if `signal` aborts. Used to
506
+ * pace the polling loop in {@link NodeRuntime.waitForListener} without blocking
507
+ * past an abort.
508
+ */
509
+ function delayUntil(ms, signal) {
510
+ return new Promise((resolve, reject) => {
511
+ if (signal?.aborted) {
512
+ reject(toAbortError(signal));
513
+ return;
514
+ }
515
+ const timer = setTimeout(() => {
516
+ signal?.removeEventListener("abort", onAbort);
517
+ resolve();
518
+ }, ms);
519
+ const onAbort = () => {
520
+ clearTimeout(timer);
521
+ reject(toAbortError(signal));
522
+ };
523
+ signal?.addEventListener("abort", onAbort, { once: true });
524
+ });
525
+ }
526
+ /**
527
+ * Concatenate streamed stdout/stderr chunks and decode them as UTF-8 text,
528
+ * reproducing the aggregated `stdout`/`stderr` strings the shell-backed
529
+ * {@link NodeRuntime.exec} path returns when a run is driven as a process for
530
+ * cancellation support.
531
+ */
532
+ function decodeChunks(chunks) {
533
+ if (chunks.length === 0) {
534
+ return "";
535
+ }
536
+ let total = 0;
537
+ for (const chunk of chunks) {
538
+ total += chunk.length;
539
+ }
540
+ const merged = new Uint8Array(total);
541
+ let offset = 0;
542
+ for (const chunk of chunks) {
543
+ merged.set(chunk, offset);
544
+ offset += chunk.length;
545
+ }
546
+ return new TextDecoder().decode(merged);
547
+ }
548
+ function toExecResult(result) {
549
+ return {
550
+ stdout: result.stdout,
551
+ stderr: result.stderr,
552
+ exitCode: result.exitCode,
553
+ };
554
+ }
555
+ /**
556
+ * Decode the raw JSON the kernel returns for a VM HTTP request into a
557
+ * structured response. The wire shape carries `status`, an optional
558
+ * `statusText`, `headers` (either an array of `[name, value]` pairs or an
559
+ * object), and a `body` that is base64-encoded when `bodyEncoding` is
560
+ * `"base64"`.
561
+ */
562
+ function parseFetchResponse(responseJson) {
563
+ const parsed = JSON.parse(responseJson);
564
+ const headers = {};
565
+ if (Array.isArray(parsed.headers)) {
566
+ for (const [name, value] of parsed.headers) {
567
+ headers[name.toLowerCase()] = value;
568
+ }
569
+ }
570
+ else if (parsed.headers) {
571
+ for (const [name, value] of Object.entries(parsed.headers)) {
572
+ headers[name.toLowerCase()] = value;
573
+ }
574
+ }
575
+ let body = parsed.body ?? "";
576
+ if (parsed.bodyEncoding === "base64" && body.length > 0) {
577
+ body = new TextDecoder().decode(Uint8Array.from(globalThis.atob(body), (char) => char.charCodeAt(0)));
578
+ }
579
+ return {
580
+ status: parsed.status ?? 0,
581
+ statusText: parsed.statusText ?? "",
582
+ headers,
583
+ body,
584
+ };
585
+ }
@@ -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
+ }