@secure-exec/core 0.0.0-agentos-dylib-base.edaa4a4

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 (277) hide show
  1. package/README.md +7 -0
  2. package/commands/[ +0 -0
  3. package/commands/_stubs +0 -0
  4. package/commands/arch +0 -0
  5. package/commands/awk +0 -0
  6. package/commands/b2sum +0 -0
  7. package/commands/base32 +0 -0
  8. package/commands/base64 +0 -0
  9. package/commands/basename +0 -0
  10. package/commands/basenc +0 -0
  11. package/commands/bash +0 -0
  12. package/commands/cat +0 -0
  13. package/commands/chcon +0 -0
  14. package/commands/chgrp +0 -0
  15. package/commands/chmod +0 -0
  16. package/commands/chown +0 -0
  17. package/commands/chroot +0 -0
  18. package/commands/cksum +0 -0
  19. package/commands/codex +0 -0
  20. package/commands/codex-exec +0 -0
  21. package/commands/column +0 -0
  22. package/commands/comm +0 -0
  23. package/commands/cp +0 -0
  24. package/commands/curl +0 -0
  25. package/commands/cut +0 -0
  26. package/commands/date +0 -0
  27. package/commands/dd +0 -0
  28. package/commands/df +0 -0
  29. package/commands/diff +0 -0
  30. package/commands/dir +0 -0
  31. package/commands/dircolors +0 -0
  32. package/commands/dirname +0 -0
  33. package/commands/du +0 -0
  34. package/commands/echo +0 -0
  35. package/commands/egrep +0 -0
  36. package/commands/env +0 -0
  37. package/commands/expand +0 -0
  38. package/commands/expr +0 -0
  39. package/commands/factor +0 -0
  40. package/commands/false +0 -0
  41. package/commands/fd +0 -0
  42. package/commands/fgrep +0 -0
  43. package/commands/file +0 -0
  44. package/commands/find +0 -0
  45. package/commands/fmt +0 -0
  46. package/commands/fold +0 -0
  47. package/commands/git +0 -0
  48. package/commands/grep +0 -0
  49. package/commands/groups +0 -0
  50. package/commands/gunzip +0 -0
  51. package/commands/gzip +0 -0
  52. package/commands/head +0 -0
  53. package/commands/hostid +0 -0
  54. package/commands/hostname +0 -0
  55. package/commands/http-test +0 -0
  56. package/commands/id +0 -0
  57. package/commands/install +0 -0
  58. package/commands/join +0 -0
  59. package/commands/jq +0 -0
  60. package/commands/kill +0 -0
  61. package/commands/link +0 -0
  62. package/commands/ln +0 -0
  63. package/commands/logname +0 -0
  64. package/commands/ls +0 -0
  65. package/commands/md5sum +0 -0
  66. package/commands/mkdir +0 -0
  67. package/commands/mkfifo +0 -0
  68. package/commands/mknod +0 -0
  69. package/commands/mktemp +0 -0
  70. package/commands/more +0 -0
  71. package/commands/mv +0 -0
  72. package/commands/nice +0 -0
  73. package/commands/nl +0 -0
  74. package/commands/nohup +0 -0
  75. package/commands/nproc +0 -0
  76. package/commands/numfmt +0 -0
  77. package/commands/od +0 -0
  78. package/commands/paste +0 -0
  79. package/commands/pathchk +0 -0
  80. package/commands/pinky +0 -0
  81. package/commands/printenv +0 -0
  82. package/commands/printf +0 -0
  83. package/commands/ptx +0 -0
  84. package/commands/pwd +0 -0
  85. package/commands/readlink +0 -0
  86. package/commands/realpath +0 -0
  87. package/commands/rev +0 -0
  88. package/commands/rg +0 -0
  89. package/commands/rm +0 -0
  90. package/commands/rmdir +0 -0
  91. package/commands/runcon +0 -0
  92. package/commands/sed +0 -0
  93. package/commands/seq +0 -0
  94. package/commands/sh +0 -0
  95. package/commands/sha1sum +0 -0
  96. package/commands/sha224sum +0 -0
  97. package/commands/sha256sum +0 -0
  98. package/commands/sha384sum +0 -0
  99. package/commands/sha512sum +0 -0
  100. package/commands/shred +0 -0
  101. package/commands/shuf +0 -0
  102. package/commands/sleep +0 -0
  103. package/commands/sort +0 -0
  104. package/commands/spawn-test-host +0 -0
  105. package/commands/split +0 -0
  106. package/commands/stat +0 -0
  107. package/commands/stdbuf +0 -0
  108. package/commands/strings +0 -0
  109. package/commands/stty +0 -0
  110. package/commands/sum +0 -0
  111. package/commands/sync +0 -0
  112. package/commands/tac +0 -0
  113. package/commands/tail +0 -0
  114. package/commands/tar +0 -0
  115. package/commands/tee +0 -0
  116. package/commands/test +0 -0
  117. package/commands/timeout +0 -0
  118. package/commands/touch +0 -0
  119. package/commands/tr +0 -0
  120. package/commands/tree +0 -0
  121. package/commands/true +0 -0
  122. package/commands/truncate +0 -0
  123. package/commands/tsort +0 -0
  124. package/commands/tty +0 -0
  125. package/commands/uname +0 -0
  126. package/commands/unexpand +0 -0
  127. package/commands/uniq +0 -0
  128. package/commands/unlink +0 -0
  129. package/commands/uptime +0 -0
  130. package/commands/users +0 -0
  131. package/commands/vdir +0 -0
  132. package/commands/wc +0 -0
  133. package/commands/which +0 -0
  134. package/commands/who +0 -0
  135. package/commands/whoami +0 -0
  136. package/commands/xargs +0 -0
  137. package/commands/xu +0 -0
  138. package/commands/yes +0 -0
  139. package/commands/yq +0 -0
  140. package/commands/zcat +0 -0
  141. package/dist/binary.d.ts +4 -0
  142. package/dist/binary.js +25 -0
  143. package/dist/bytes.d.ts +2 -0
  144. package/dist/bytes.js +6 -0
  145. package/dist/callbacks.d.ts +41 -0
  146. package/dist/callbacks.js +94 -0
  147. package/dist/cargo.d.ts +2 -0
  148. package/dist/cargo.js +142 -0
  149. package/dist/correlation.d.ts +10 -0
  150. package/dist/correlation.js +49 -0
  151. package/dist/descriptors.d.ts +80 -0
  152. package/dist/descriptors.js +55 -0
  153. package/dist/event-buffer.d.ts +90 -0
  154. package/dist/event-buffer.js +313 -0
  155. package/dist/ext.d.ts +7 -0
  156. package/dist/ext.js +13 -0
  157. package/dist/filesystem.d.ts +41 -0
  158. package/dist/filesystem.js +70 -0
  159. package/dist/frame-payload-codec.d.ts +8 -0
  160. package/dist/frame-payload-codec.js +14 -0
  161. package/dist/frame-rpc.d.ts +40 -0
  162. package/dist/frame-rpc.js +81 -0
  163. package/dist/frame-stream.d.ts +34 -0
  164. package/dist/frame-stream.js +109 -0
  165. package/dist/framing.d.ts +8 -0
  166. package/dist/framing.js +23 -0
  167. package/dist/generated/AcpLimitsConfig.d.ts +4 -0
  168. package/dist/generated/AcpLimitsConfig.js +2 -0
  169. package/dist/generated/CreateVmConfig.d.ts +19 -0
  170. package/dist/generated/CreateVmConfig.js +1 -0
  171. package/dist/generated/FsPermissionRule.d.ts +6 -0
  172. package/dist/generated/FsPermissionRule.js +1 -0
  173. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  174. package/dist/generated/FsPermissionRuleSet.js +1 -0
  175. package/dist/generated/FsPermissionScope.d.ts +3 -0
  176. package/dist/generated/FsPermissionScope.js +1 -0
  177. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  178. package/dist/generated/HttpLimitsConfig.js +2 -0
  179. package/dist/generated/JsModuleResolution.d.ts +1 -0
  180. package/dist/generated/JsModuleResolution.js +2 -0
  181. package/dist/generated/JsRuntimeConfig.d.ts +26 -0
  182. package/dist/generated/JsRuntimeConfig.js +1 -0
  183. package/dist/generated/JsRuntimeLimitsConfig.d.ts +8 -0
  184. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  185. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  186. package/dist/generated/JsRuntimePlatform.js +2 -0
  187. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  188. package/dist/generated/MountPluginDescriptor.js +2 -0
  189. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  190. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  191. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  192. package/dist/generated/PatternPermissionRule.js +1 -0
  193. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  194. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  195. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  196. package/dist/generated/PatternPermissionScope.js +1 -0
  197. package/dist/generated/PermissionMode.d.ts +1 -0
  198. package/dist/generated/PermissionMode.js +2 -0
  199. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  200. package/dist/generated/PermissionsPolicy.js +1 -0
  201. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  202. package/dist/generated/PluginLimitsConfig.js +2 -0
  203. package/dist/generated/PythonLimitsConfig.d.ts +6 -0
  204. package/dist/generated/PythonLimitsConfig.js +2 -0
  205. package/dist/generated/ResourceLimitsConfig.d.ts +22 -0
  206. package/dist/generated/ResourceLimitsConfig.js +2 -0
  207. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  208. package/dist/generated/RootFilesystemConfig.js +1 -0
  209. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  210. package/dist/generated/RootFilesystemEntry.js +1 -0
  211. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  212. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  213. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  214. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  215. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  216. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  217. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  218. package/dist/generated/RootFilesystemMode.js +2 -0
  219. package/dist/generated/ToolLimitsConfig.d.ts +10 -0
  220. package/dist/generated/ToolLimitsConfig.js +2 -0
  221. package/dist/generated/VmDnsConfig.d.ts +6 -0
  222. package/dist/generated/VmDnsConfig.js +2 -0
  223. package/dist/generated/VmLimitsConfig.d.ts +18 -0
  224. package/dist/generated/VmLimitsConfig.js +1 -0
  225. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  226. package/dist/generated/VmListenPolicyConfig.js +2 -0
  227. package/dist/generated/WasmLimitsConfig.d.ts +5 -0
  228. package/dist/generated/WasmLimitsConfig.js +2 -0
  229. package/dist/generated-protocol.d.ts +1055 -0
  230. package/dist/generated-protocol.js +2921 -0
  231. package/dist/index.d.ts +29 -0
  232. package/dist/index.js +28 -0
  233. package/dist/json.d.ts +2 -0
  234. package/dist/json.js +20 -0
  235. package/dist/kernel-proxy.d.ts +165 -0
  236. package/dist/kernel-proxy.js +1760 -0
  237. package/dist/message-frame-transport.d.ts +29 -0
  238. package/dist/message-frame-transport.js +81 -0
  239. package/dist/native-client.d.ts +42 -0
  240. package/dist/native-client.js +126 -0
  241. package/dist/node-runtime.d.ts +516 -0
  242. package/dist/node-runtime.js +823 -0
  243. package/dist/numbers.d.ts +1 -0
  244. package/dist/numbers.js +8 -0
  245. package/dist/ownership.d.ts +18 -0
  246. package/dist/ownership.js +77 -0
  247. package/dist/permissions.d.ts +29 -0
  248. package/dist/permissions.js +68 -0
  249. package/dist/process.d.ts +22 -0
  250. package/dist/process.js +97 -0
  251. package/dist/protocol-client.d.ts +48 -0
  252. package/dist/protocol-client.js +181 -0
  253. package/dist/protocol-frames.d.ts +68 -0
  254. package/dist/protocol-frames.js +139 -0
  255. package/dist/protocol-maps.d.ts +31 -0
  256. package/dist/protocol-maps.js +253 -0
  257. package/dist/protocol-schema.d.ts +10 -0
  258. package/dist/protocol-schema.js +11 -0
  259. package/dist/request-payloads.d.ts +154 -0
  260. package/dist/request-payloads.js +241 -0
  261. package/dist/response-payloads.d.ts +124 -0
  262. package/dist/response-payloads.js +180 -0
  263. package/dist/sidecar-client.d.ts +17 -0
  264. package/dist/sidecar-client.js +1 -0
  265. package/dist/sidecar-errors.d.ts +15 -0
  266. package/dist/sidecar-errors.js +30 -0
  267. package/dist/sidecar-process.d.ts +281 -0
  268. package/dist/sidecar-process.js +839 -0
  269. package/dist/state.d.ts +40 -0
  270. package/dist/state.js +44 -0
  271. package/dist/test-runtime.d.ts +536 -0
  272. package/dist/test-runtime.js +2153 -0
  273. package/dist/vm-config.d.ts +31 -0
  274. package/dist/vm-config.js +1 -0
  275. package/fixtures/alpine-defaults.json +520 -0
  276. package/fixtures/base-filesystem.json +528 -0
  277. package/package.json +212 -0
@@ -0,0 +1,823 @@
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 { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { createInMemoryFileSystem, createKernel, createNodeRuntime, createWasmVmRuntime, NodeFileSystem, } from "./test-runtime.js";
24
+ /** Repository root, used to locate the in-repo WASM command build output. */
25
+ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
26
+ /**
27
+ * In-repo build output for the WASM coreutils/shell command binaries, produced
28
+ * by the Rust command build (`make -C registry/native wasm`). Only present in a
29
+ * developer checkout; preferred when it exists so local edits are picked up
30
+ * without re-vendoring.
31
+ */
32
+ const REPO_COMMANDS_DIR = path.join(REPO_ROOT, "registry/native/target/wasm32-wasip1/release/commands");
33
+ /**
34
+ * Commands vendored into the published `@secure-exec/core` package by
35
+ * `scripts/copy-wasm-commands.mjs` (listed in `files` as `commands`). This is
36
+ * the directory a real `npm install secure-exec` resolves: from the compiled
37
+ * `dist/node-runtime.js` it sits at `<package>/commands`. This is the analogue
38
+ * of how the sidecar binary ships inside `@secure-exec/sidecar`.
39
+ */
40
+ const BUNDLED_COMMANDS_DIR = fileURLToPath(new URL("../commands", import.meta.url));
41
+ /**
42
+ * Resolve the directory holding the WASM command binaries (the source of the
43
+ * guest `sh` the kernel needs to spawn any process). Precedence:
44
+ *
45
+ * 1. explicit `commandsDir` option,
46
+ * 2. `SECURE_EXEC_WASM_COMMANDS_DIR` env var,
47
+ * 3. the in-repo build output (developer checkout), when present,
48
+ * 4. the commands vendored into the installed package (published installs).
49
+ *
50
+ * The in-repo path wins over the bundled copy so local development picks up
51
+ * freshly built commands without re-vendoring. A fresh `npm install` has no
52
+ * in-repo path, so it falls through to the bundled copy.
53
+ */
54
+ function resolveCommandsDir(explicit) {
55
+ if (explicit !== undefined) {
56
+ return explicit;
57
+ }
58
+ const fromEnv = process.env.SECURE_EXEC_WASM_COMMANDS_DIR;
59
+ if (fromEnv) {
60
+ return fromEnv;
61
+ }
62
+ if (existsSync(REPO_COMMANDS_DIR)) {
63
+ return REPO_COMMANDS_DIR;
64
+ }
65
+ return BUNDLED_COMMANDS_DIR;
66
+ }
67
+ /**
68
+ * Secure-by-default permission policy applied when the caller passes no
69
+ * `permissions`. Outward-facing capabilities are denied: there is **no network
70
+ * access** (and no host callbacks) by default — guest code cannot reach the
71
+ * network until you opt in. The filesystem, child-process, process, and env
72
+ * scopes are allowed because they are fully virtualized (the guest only ever
73
+ * sees the VM's in-memory filesystem and kernel-managed processes, never the
74
+ * real host) and are required for the runtime to execute a guest program at
75
+ * all. Tighten or widen any scope by passing your own `permissions`.
76
+ */
77
+ const DEFAULT_PERMISSIONS = {
78
+ fs: "allow",
79
+ childProcess: "allow",
80
+ process: "allow",
81
+ env: "allow",
82
+ network: "deny",
83
+ };
84
+ /** Guest path a `nodeModules` mount is projected at by default. */
85
+ const DEFAULT_NODE_MODULES_GUEST_PATH = "/tmp/node_modules";
86
+ let nextProgramId = 0;
87
+ let nextResidentRequestId = 0;
88
+ /**
89
+ * Guest preamble exposing `globalThis.callHostTool(name, input?)`: an ergonomic
90
+ * async wrapper over the host-tool invocation path. It runs the registered tool
91
+ * as the guest would by hand (`<tool> --json <input>` through
92
+ * `node:child_process`), so it inherits every security property of that path:
93
+ * the `tool` permission scope, the tool's input-schema validation, and the
94
+ * host-side handler all still apply. It adds no new trust surface; it only
95
+ * removes the manual `execFile`/JSON boilerplate so guest and agent code can do
96
+ * `const out = await callHostTool("add", { a, b })`. The value is a single line
97
+ * so it shifts guest source line numbers by at most one in stack traces.
98
+ *
99
+ * Note: the tool still runs through a guest process. Eliminating that spawn would
100
+ * require a dedicated async guest-to-host tool channel (the synchronous sync-RPC
101
+ * path cannot be used: it runs on the sidecar's main sync-RPC thread and a host
102
+ * round-trip would block it); that is a separate, test-gated change.
103
+ */
104
+ const HOST_TOOL_PREAMBLE = `globalThis.callHostTool = (name, input = {}) => import("node:child_process").then(({ execFile }) => new Promise((resolve, reject) => { execFile(name, [name, "--json", JSON.stringify(input)], { maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(String(stderr || "").trim() || error.message)); return; } const text = String(stdout ?? "").trim(); let reply; try { reply = text ? JSON.parse(text) : undefined; } catch { reject(new Error("host tool returned invalid JSON: " + text)); return; } if (reply && reply.ok === false) { reject(new Error(reply.error || "host tool failed")); return; } resolve(reply && typeof reply === "object" && "result" in reply ? reply.result : reply); }); }));`;
105
+ /** Prepend the host-tool helper preamble to guest program source. */
106
+ function withHostToolPreamble(code) {
107
+ return `${HOST_TOOL_PREAMBLE}\n${code}`;
108
+ }
109
+ const RESIDENT_READY_PREFIX = "__SECURE_EXEC_RESIDENT_READY__";
110
+ const RESIDENT_RESULT_PREFIX = "__SECURE_EXEC_RESIDENT_RESULT__";
111
+ /**
112
+ * Ergonomic, batteries-included runtime for executing guest JavaScript.
113
+ *
114
+ * Construct one with {@link NodeRuntime.create}, run programs with
115
+ * {@link NodeRuntime.exec} / {@link NodeRuntime.run}, and release the VM with
116
+ * {@link NodeRuntime.dispose}. A single instance can run many programs; each
117
+ * call executes a fresh guest process.
118
+ */
119
+ export class NodeRuntime {
120
+ kernel;
121
+ constructor(kernel) {
122
+ this.kernel = kernel;
123
+ }
124
+ /**
125
+ * Boot a VM and return a ready-to-use runtime. Spawns the sidecar, opens a
126
+ * session, creates the VM with a bootstrapped root filesystem, mounts the
127
+ * shell and Node runtimes, and waits for the VM to report ready.
128
+ */
129
+ static async create(options = {}) {
130
+ const commandsDir = resolveCommandsDir(options.commandsDir);
131
+ // Seed caller-provided files into the VM's in-memory filesystem before
132
+ // boot so they are part of the root filesystem snapshot the guest sees
133
+ // (e.g. projected npm packages or fixtures). The host filesystem is
134
+ // never exposed; only these bytes are copied in.
135
+ const filesystem = createInMemoryFileSystem();
136
+ if (options.files) {
137
+ for (const [filePath, content] of Object.entries(options.files)) {
138
+ await filesystem.writeFile(filePath, content);
139
+ }
140
+ }
141
+ // Project host directories into the VM, Docker-style. NodeFileSystem
142
+ // reads lazily through the VFS so large trees never traverse the
143
+ // protocol frame as a single blob.
144
+ const hostMounts = [...(options.mounts ?? [])];
145
+ // The `nodeModules` helper is sugar over a single host directory mount:
146
+ // project the whole host `node_modules` at a guest `node_modules` on the
147
+ // resolution path so any package inside resolves like real Node would.
148
+ if (options.nodeModules !== undefined) {
149
+ const nodeModules = typeof options.nodeModules === "string"
150
+ ? { hostPath: options.nodeModules }
151
+ : options.nodeModules;
152
+ hostMounts.push({
153
+ guestPath: nodeModules.guestPath ?? DEFAULT_NODE_MODULES_GUEST_PATH,
154
+ hostPath: nodeModules.hostPath,
155
+ readOnly: true,
156
+ });
157
+ }
158
+ const mounts = hostMounts.map((mount) => ({
159
+ path: mount.guestPath,
160
+ fs: new NodeFileSystem({ root: mount.hostPath }),
161
+ readOnly: mount.readOnly ?? true,
162
+ }));
163
+ // Grant the `tool` scope when the caller registers tools but does not set
164
+ // their own tool policy, so the registered tools are actually invocable.
165
+ const toolDefaults = options.tools &&
166
+ Object.keys(options.tools).length > 0 &&
167
+ options.permissions?.tool === undefined
168
+ ? { tool: "allow" }
169
+ : {};
170
+ const kernel = createKernel({
171
+ filesystem,
172
+ mounts: mounts.length > 0 ? mounts : undefined,
173
+ // Merge the caller's policy over the secure default so partial
174
+ // opt-ins work: `{ network: "allow" }` enables the network while the
175
+ // execution essentials (fs/childProcess/process/env) stay granted.
176
+ permissions: {
177
+ ...DEFAULT_PERMISSIONS,
178
+ ...toolDefaults,
179
+ ...options.permissions,
180
+ },
181
+ env: options.env,
182
+ cwd: options.cwd,
183
+ sidecar: options.sidecar,
184
+ onBootTiming: (timing) => options.onBootTiming?.(timing),
185
+ loopbackExemptPorts: options.loopbackExemptPorts,
186
+ });
187
+ try {
188
+ // The shell runtime provides `sh` plus coreutils; the Node runtime
189
+ // provides the real V8-backed `node`. `sh` is REQUIRED to spawn any
190
+ // process: the kernel runs every command through a shell, so without
191
+ // `sh` nothing can be spawned, including the guest `node` program we
192
+ // run here and any child the guest spawns via node:child_process.
193
+ await measureBootTiming("runtime_mount_wasm", options.onBootTiming, () => kernel.mount(createWasmVmRuntime({ commandDirs: [commandsDir] })));
194
+ await measureBootTiming("runtime_mount_node", options.onBootTiming, () => kernel.mount(createNodeRuntime()));
195
+ // Register host tools after the runtimes are mounted so they are
196
+ // installed as guest commands the moment the VM is ready.
197
+ const tools = options.tools;
198
+ if (tools && Object.keys(tools).length > 0) {
199
+ await measureBootTiming("host_tools", options.onBootTiming, () => kernel.registerHostTools(tools));
200
+ }
201
+ }
202
+ catch (error) {
203
+ await kernel.dispose().catch(() => { });
204
+ throw error;
205
+ }
206
+ return new NodeRuntime(kernel);
207
+ }
208
+ async createResidentRunner(_options = {}) {
209
+ return ResidentNodeRunner.create(this);
210
+ }
211
+ /**
212
+ * Run `code` as a guest Node program and capture its output.
213
+ *
214
+ * The source is written to an ES module inside the VM and executed with
215
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`).
216
+ */
217
+ async exec(code, options = {}) {
218
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
219
+ await this.kernel.writeFile(programPath, withHostToolPreamble(code));
220
+ return this.runProgram(programPath, options);
221
+ }
222
+ /**
223
+ * Run an already-written guest program file to completion and capture its
224
+ * output, honoring a caller-supplied `signal` for cancellation.
225
+ *
226
+ * Without a `signal`, this runs the program through the shell (`node <file>`)
227
+ * exactly as before. With a `signal`, it starts the program as a guest
228
+ * process so the run can be cancelled: when the signal aborts, the process is
229
+ * killed inside the VM (the kernel delivers `SIGTERM`) and the call rejects
230
+ * with the signal's abort reason.
231
+ */
232
+ async runProgram(programPath, options) {
233
+ const signal = options.signal;
234
+ if (!signal) {
235
+ const result = await this.kernel.exec(`node ${programPath}`, {
236
+ env: options.env,
237
+ cwd: options.cwd,
238
+ stdin: options.stdin,
239
+ timeout: options.timeout,
240
+ onStdout: options.onStdout,
241
+ onStderr: options.onStderr,
242
+ });
243
+ return toExecResult(result);
244
+ }
245
+ if (signal.aborted) {
246
+ throw toAbortError(signal);
247
+ }
248
+ // A signal was supplied, so run the program as a guest process we can
249
+ // kill: aborting the signal maps to a kernel kill of the underlying
250
+ // process. Aggregate the streamed output ourselves to reproduce the
251
+ // run-to-completion result that the shell path returns.
252
+ const stdoutChunks = [];
253
+ const stderrChunks = [];
254
+ const proc = this.kernel.spawn("node", [programPath], {
255
+ env: options.env,
256
+ cwd: options.cwd,
257
+ onStdout: (chunk) => {
258
+ stdoutChunks.push(chunk);
259
+ options.onStdout?.(chunk);
260
+ },
261
+ onStderr: (chunk) => {
262
+ stderrChunks.push(chunk);
263
+ options.onStderr?.(chunk);
264
+ },
265
+ streamStdin: options.stdin !== undefined,
266
+ });
267
+ if (options.stdin !== undefined) {
268
+ proc.writeStdin(options.stdin);
269
+ proc.closeStdin();
270
+ }
271
+ const onAbort = () => {
272
+ // Deliver SIGTERM to cancel the in-flight run inside the VM.
273
+ proc.kill(toSignalNumber("SIGTERM"));
274
+ };
275
+ signal.addEventListener("abort", onAbort, { once: true });
276
+ let timer;
277
+ if (options.timeout !== undefined) {
278
+ timer = setTimeout(() => {
279
+ proc.kill(toSignalNumber("SIGKILL"));
280
+ }, options.timeout);
281
+ }
282
+ try {
283
+ const exitCode = await proc.wait();
284
+ if (signal.aborted) {
285
+ throw toAbortError(signal);
286
+ }
287
+ return {
288
+ stdout: decodeChunks(stdoutChunks),
289
+ stderr: decodeChunks(stderrChunks),
290
+ exitCode,
291
+ };
292
+ }
293
+ finally {
294
+ if (timer !== undefined) {
295
+ clearTimeout(timer);
296
+ }
297
+ signal.removeEventListener("abort", onAbort);
298
+ }
299
+ }
300
+ /**
301
+ * Start `code` as a long-running guest Node program and return a live handle
302
+ * to it, without waiting for it to finish.
303
+ *
304
+ * The source is written to an ES module inside the VM and started with
305
+ * `node <file>`; it runs as standard ESM (top-level `await`, `import`). The
306
+ * returned {@link NodeRuntimeProcess} lets you stream output, write to stdin,
307
+ * signal or kill the process, and await its exit. Pass `onStdout`/`onStderr`
308
+ * to receive output chunks as they are produced.
309
+ *
310
+ * Use this for guests that do not run to completion, such as a dev server you
311
+ * later drive with {@link NodeRuntime.fetch}:
312
+ *
313
+ * ```ts
314
+ * const server = await rt.spawn(`
315
+ * import http from "node:http";
316
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
317
+ * `);
318
+ * const res = await rt.fetch(3000, { path: "/" });
319
+ * server.kill();
320
+ * await server.wait();
321
+ * ```
322
+ */
323
+ async spawn(code, options = {}) {
324
+ const programPath = `/tmp/secure-exec-program-${nextProgramId++}.mjs`;
325
+ await this.kernel.writeFile(programPath, withHostToolPreamble(code));
326
+ const proc = this.kernel.spawn("node", [programPath], {
327
+ env: options.env,
328
+ cwd: options.cwd,
329
+ onStdout: options.onStdout,
330
+ onStderr: options.onStderr,
331
+ // Keep stdin open so callers can stream input via writeStdin and signal
332
+ // end-of-input with closeStdin.
333
+ streamStdin: true,
334
+ });
335
+ return {
336
+ pid: proc.pid,
337
+ writeStdin(data) {
338
+ proc.writeStdin(data);
339
+ },
340
+ closeStdin() {
341
+ proc.closeStdin();
342
+ },
343
+ kill(signal) {
344
+ proc.kill(toSignalNumber(signal));
345
+ },
346
+ wait() {
347
+ return proc.wait();
348
+ },
349
+ get exitCode() {
350
+ return proc.exitCode;
351
+ },
352
+ };
353
+ }
354
+ /**
355
+ * Run `code` and return the JSON-serializable value it produces.
356
+ *
357
+ * The guest exposes a `__return(value)` function; call it with a
358
+ * JSON-serializable value and that value is decoded on the host as
359
+ * `result.value`. If `__return` is never called the value is `undefined`.
360
+ * stdout/stderr/exitCode are still captured.
361
+ */
362
+ async run(code, options = {}) {
363
+ const id = nextProgramId++;
364
+ const resultPath = `/tmp/secure-exec-result-${id}.json`;
365
+ const programPath = `/tmp/secure-exec-program-${id}.mjs`;
366
+ // Inject the __return helper as a module-level preamble, then the user
367
+ // code at module top level. Import declarations (preamble's and the
368
+ // user's) are hoisted, so __return is defined before the user's
369
+ // executable code runs — and the user keeps full ESM semantics
370
+ // (top-level `import` and top-level `await` both work). Do NOT wrap the
371
+ // user code in an IIFE: that would push their top-level `import`
372
+ // statements inside a function and make them a SyntaxError.
373
+ const wrapped = [
374
+ `import { writeFileSync as __writeFileSync } from "node:fs";`,
375
+ HOST_TOOL_PREAMBLE,
376
+ `globalThis.__return = (value) => {`,
377
+ ` __writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify(value === undefined ? null : value));`,
378
+ `};`,
379
+ code,
380
+ ].join("\n");
381
+ await this.kernel.writeFile(programPath, wrapped);
382
+ const exec = await this.runProgram(programPath, options);
383
+ let value;
384
+ if (exec.exitCode === 0) {
385
+ try {
386
+ const bytes = await this.kernel.readFile(resultPath);
387
+ value = JSON.parse(new TextDecoder().decode(bytes));
388
+ }
389
+ catch {
390
+ // No __return() call (or unreadable result): leave value undefined.
391
+ }
392
+ }
393
+ return { ...exec, value };
394
+ }
395
+ /**
396
+ * Drive an HTTP request to a guest HTTP server listening inside the VM and
397
+ * read the response back on the host.
398
+ *
399
+ * Point this at a port a guest program is serving, for example a dev server
400
+ * started with {@link NodeRuntime.exec}. The
401
+ * request and response never leave the VM: the connection is made to the
402
+ * guest's loopback listener through the kernel socket table, so this works
403
+ * even when guest network egress is denied.
404
+ *
405
+ * ```ts
406
+ * const res = await rt.fetch(3000, { path: "/health" });
407
+ * console.log(res.status, res.body);
408
+ * ```
409
+ */
410
+ async fetch(port, input) {
411
+ const body = input.body === undefined
412
+ ? undefined
413
+ : typeof input.body === "string"
414
+ ? input.body
415
+ : new TextDecoder().decode(input.body);
416
+ const responseJson = await this.kernel.vmFetch({
417
+ port,
418
+ method: input.method ?? "GET",
419
+ path: input.path,
420
+ headersJson: JSON.stringify(input.headers ?? {}),
421
+ body,
422
+ });
423
+ return parseFetchResponse(responseJson);
424
+ }
425
+ /**
426
+ * Look up a guest TCP listener once and return it, or `null` when nothing is
427
+ * listening yet.
428
+ *
429
+ * This is the immediate, non-blocking check behind
430
+ * {@link NodeRuntime.waitForListener}: it asks the kernel socket table
431
+ * whether a guest process is accepting connections on the requested `port`
432
+ * (optionally narrowed by `host`/`path`) and returns the match, or `null` if
433
+ * none is up. Use {@link NodeRuntime.waitForListener} when you want to block
434
+ * until one appears.
435
+ *
436
+ * ```ts
437
+ * const listener = rt.findListener({ port: 3000 });
438
+ * if (listener) console.log("up on pid", listener.processId);
439
+ * ```
440
+ */
441
+ findListener(query) {
442
+ const match = this.kernel.socketTable.findListener({
443
+ port: query.port,
444
+ ...(query.host !== undefined ? { host: query.host } : {}),
445
+ ...(query.path !== undefined ? { path: query.path } : {}),
446
+ });
447
+ return match ?? null;
448
+ }
449
+ /**
450
+ * Block until a guest TCP listener is accepting connections on the requested
451
+ * `port` (optionally narrowed by `host`/`path`), then resolve with it.
452
+ *
453
+ * This is the companion to {@link NodeRuntime.spawn} and
454
+ * {@link NodeRuntime.fetch} for dev-server scenarios: start a server, wait
455
+ * until it is actually listening, then drive requests into it. The kernel
456
+ * socket table is polled until a matching listener appears or the wait is
457
+ * cut short. If `timeoutMs` elapses (default 10000) or the supplied `signal`
458
+ * aborts first, the returned promise rejects.
459
+ *
460
+ * ```ts
461
+ * const server = await rt.spawn(`
462
+ * import http from "node:http";
463
+ * http.createServer((_, res) => res.end("ok")).listen(3000);
464
+ * `);
465
+ * const listener = await rt.waitForListener({ port: 3000 });
466
+ * const res = await rt.fetch(listener.port ?? 3000, { path: "/" });
467
+ * server.kill();
468
+ * await server.wait();
469
+ * ```
470
+ */
471
+ async waitForListener(query, options = {}) {
472
+ const timeoutMs = options.timeoutMs ?? 10_000;
473
+ const pollIntervalMs = options.pollIntervalMs ?? 50;
474
+ const signal = options.signal;
475
+ const deadline = Date.now() + timeoutMs;
476
+ for (;;) {
477
+ if (signal?.aborted) {
478
+ throw toAbortError(signal);
479
+ }
480
+ // Await a fresh lookup rather than reading the synchronous cache,
481
+ // which starts null and would otherwise let this loop poll a stale
482
+ // null even after the listener is up (issue #92).
483
+ const match = (await this.kernel.socketTable.findListenerAsync({
484
+ port: query.port,
485
+ ...(query.host !== undefined ? { host: query.host } : {}),
486
+ ...(query.path !== undefined ? { path: query.path } : {}),
487
+ }));
488
+ if (match) {
489
+ return match;
490
+ }
491
+ if (Date.now() >= deadline) {
492
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for a listener on port ${query.port}`);
493
+ }
494
+ await delayUntil(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
495
+ }
496
+ }
497
+ /**
498
+ * Register host-side tools the guest can invoke as shell commands, after the
499
+ * VM is already running. Each entry becomes a named guest command; when the
500
+ * guest runs it, the invocation round-trips back to the host and runs the
501
+ * tool's `handler`, whose return value is delivered back to the guest. This
502
+ * is the same capability as the `tools` create option, exposed for adding
503
+ * tools to a live runtime. See `tools` on {@link NodeRuntime.create} for the
504
+ * invocation shape and permission behavior.
505
+ *
506
+ * When registering tools this way, make sure the `tool` permission scope is
507
+ * granted (for example `permissions: { tool: "allow" }` on
508
+ * {@link NodeRuntime.create}) so the tools are invocable.
509
+ */
510
+ async registerTools(tools) {
511
+ await this.kernel.registerHostTools(tools);
512
+ }
513
+ /**
514
+ * Write a file into the VM's virtual filesystem, creating parent
515
+ * directories as needed. Use this to project assets or npm packages into
516
+ * the sandbox after boot; the host filesystem is never touched.
517
+ */
518
+ async writeFile(filePath, content) {
519
+ await this.kernel.writeFile(filePath, content);
520
+ }
521
+ /** Read a file from the VM's virtual filesystem as raw bytes. */
522
+ async readFile(filePath) {
523
+ return this.kernel.readFile(filePath);
524
+ }
525
+ /** Tear down the VM and release the sidecar. */
526
+ async dispose() {
527
+ await this.kernel.dispose();
528
+ }
529
+ }
530
+ const RESIDENT_RUNNER_SOURCE = `
531
+ import { Buffer } from "node:buffer";
532
+ import { createInterface } from "node:readline";
533
+
534
+ const readyPrefix = ${JSON.stringify(RESIDENT_READY_PREFIX)};
535
+ const resultPrefix = ${JSON.stringify(RESIDENT_RESULT_PREFIX)};
536
+ console.log(readyPrefix);
537
+
538
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
539
+ for await (const line of rl) {
540
+ let request;
541
+ try {
542
+ request = JSON.parse(line);
543
+ const source = Buffer.from(String(request.code), "utf8").toString("base64");
544
+ await import(\`data:text/javascript;base64,\${source}#\${request.id}\`);
545
+ process.stdout.write(resultPrefix + JSON.stringify({
546
+ id: request.id,
547
+ exitCode: 0,
548
+ stderr: "",
549
+ }) + "\\n");
550
+ } catch (error) {
551
+ process.stdout.write(resultPrefix + JSON.stringify({
552
+ id: request?.id,
553
+ exitCode: 1,
554
+ stderr: error instanceof Error ? (error.stack ?? error.message) : String(error),
555
+ }) + "\\n");
556
+ }
557
+ }
558
+ `;
559
+ class ResidentNodeRunner {
560
+ proc = null;
561
+ stdoutBuffer = "";
562
+ active = null;
563
+ readyPromise;
564
+ resolveReady;
565
+ rejectReady;
566
+ constructor() {
567
+ this.readyPromise = new Promise((resolve, reject) => {
568
+ this.resolveReady = resolve;
569
+ this.rejectReady = reject;
570
+ });
571
+ }
572
+ static async create(runtime) {
573
+ const runner = new ResidentNodeRunner();
574
+ runner.proc = await runtime.spawn(RESIDENT_RUNNER_SOURCE, {
575
+ onStdout: (chunk) => runner.handleStdout(chunk),
576
+ onStderr: (chunk) => runner.handleStderr(chunk),
577
+ });
578
+ runner.proc.wait().then((exitCode) => {
579
+ const error = new Error(`resident runner exited before completing request: ${exitCode}`);
580
+ runner.rejectReady(error);
581
+ runner.active?.reject(error);
582
+ runner.active = null;
583
+ }, (error) => {
584
+ const normalized = error instanceof Error ? error : new Error(String(error));
585
+ runner.rejectReady(normalized);
586
+ runner.active?.reject(normalized);
587
+ runner.active = null;
588
+ });
589
+ await runner.readyPromise;
590
+ return runner;
591
+ }
592
+ async exec(code, options = {}) {
593
+ await this.readyPromise;
594
+ if (!this.proc) {
595
+ throw new Error("resident runner is not running");
596
+ }
597
+ if (this.active) {
598
+ throw new Error("resident runner supports one in-flight exec");
599
+ }
600
+ const proc = this.proc;
601
+ const id = nextResidentRequestId++;
602
+ return new Promise((resolve, reject) => {
603
+ const active = {
604
+ id,
605
+ stdout: [],
606
+ stderr: [],
607
+ resolve,
608
+ reject,
609
+ timer: undefined,
610
+ };
611
+ if (options.timeout !== undefined) {
612
+ active.timer = setTimeout(() => {
613
+ proc.kill("SIGKILL");
614
+ this.active = null;
615
+ reject(new Error(`resident runner timed out after ${options.timeout}ms`));
616
+ }, options.timeout);
617
+ }
618
+ this.active = active;
619
+ proc.writeStdin(`${JSON.stringify({ id, code })}\n`);
620
+ });
621
+ }
622
+ async dispose() {
623
+ const proc = this.proc;
624
+ this.proc = null;
625
+ this.active = null;
626
+ if (!proc) {
627
+ return;
628
+ }
629
+ proc.kill("SIGTERM");
630
+ await proc.wait().catch(() => { });
631
+ }
632
+ handleStdout(chunk) {
633
+ this.stdoutBuffer += new TextDecoder().decode(chunk);
634
+ while (true) {
635
+ const newlineIndex = this.stdoutBuffer.indexOf("\n");
636
+ if (newlineIndex < 0) {
637
+ break;
638
+ }
639
+ const rawLine = this.stdoutBuffer.slice(0, newlineIndex);
640
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
641
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
642
+ if (line === RESIDENT_READY_PREFIX) {
643
+ this.resolveReady();
644
+ continue;
645
+ }
646
+ if (line.startsWith(RESIDENT_RESULT_PREFIX)) {
647
+ this.finishRequest(line.slice(RESIDENT_RESULT_PREFIX.length));
648
+ continue;
649
+ }
650
+ this.active?.stdout.push(new TextEncoder().encode(`${line}\n`));
651
+ }
652
+ }
653
+ handleStderr(chunk) {
654
+ this.active?.stderr.push(chunk);
655
+ }
656
+ finishRequest(payload) {
657
+ const active = this.active;
658
+ if (!active) {
659
+ return;
660
+ }
661
+ let parsed;
662
+ try {
663
+ parsed = JSON.parse(payload);
664
+ }
665
+ catch (error) {
666
+ active.reject(error instanceof Error ? error : new Error(String(error)));
667
+ this.active = null;
668
+ return;
669
+ }
670
+ if (parsed.id !== active.id) {
671
+ active.reject(new Error(`resident runner response id mismatch: ${parsed.id}`));
672
+ this.active = null;
673
+ return;
674
+ }
675
+ if (active.timer !== undefined) {
676
+ clearTimeout(active.timer);
677
+ }
678
+ this.active = null;
679
+ const stderr = `${decodeChunks(active.stderr)}${parsed.stderr ?? ""}`;
680
+ active.resolve({
681
+ stdout: decodeChunks(active.stdout),
682
+ stderr,
683
+ exitCode: parsed.exitCode ?? 1,
684
+ });
685
+ }
686
+ }
687
+ async function measureBootTiming(phase, onBootTiming, fn) {
688
+ const start = performance.now();
689
+ try {
690
+ return await fn();
691
+ }
692
+ finally {
693
+ onBootTiming?.({ phase, durationMs: performance.now() - start });
694
+ }
695
+ }
696
+ /**
697
+ * Common POSIX signal numbers, used to translate a signal name passed to
698
+ * {@link NodeRuntimeProcess.kill} into the numeric signal the kernel expects.
699
+ */
700
+ const SIGNAL_NUMBERS = {
701
+ SIGHUP: 1,
702
+ SIGINT: 2,
703
+ SIGQUIT: 3,
704
+ SIGKILL: 9,
705
+ SIGUSR1: 10,
706
+ SIGUSR2: 12,
707
+ SIGTERM: 15,
708
+ SIGSTOP: 19,
709
+ SIGCONT: 18,
710
+ };
711
+ /**
712
+ * Normalize a signal passed to {@link NodeRuntimeProcess.kill} into the numeric
713
+ * signal the kernel expects. Accepts a signal name (e.g. `"SIGKILL"`) or a raw
714
+ * number; defaults to `SIGTERM` when omitted.
715
+ */
716
+ function toSignalNumber(signal) {
717
+ if (signal === undefined) {
718
+ return SIGNAL_NUMBERS.SIGTERM;
719
+ }
720
+ if (typeof signal === "number") {
721
+ return signal;
722
+ }
723
+ const resolved = SIGNAL_NUMBERS[signal];
724
+ if (resolved === undefined) {
725
+ throw new Error(`Unknown signal: ${signal}`);
726
+ }
727
+ return resolved;
728
+ }
729
+ /**
730
+ * Build the error a {@link NodeRuntime.waitForListener} wait rejects with when
731
+ * its abort signal fires, preferring the signal's own `reason` when present.
732
+ */
733
+ function toAbortError(signal) {
734
+ const reason = signal.reason;
735
+ if (reason instanceof Error) {
736
+ return reason;
737
+ }
738
+ const error = new Error("The listener wait was aborted");
739
+ error.name = "AbortError";
740
+ return error;
741
+ }
742
+ /**
743
+ * Resolve after `ms` milliseconds, or reject early if `signal` aborts. Used to
744
+ * pace the polling loop in {@link NodeRuntime.waitForListener} without blocking
745
+ * past an abort.
746
+ */
747
+ function delayUntil(ms, signal) {
748
+ return new Promise((resolve, reject) => {
749
+ if (signal?.aborted) {
750
+ reject(toAbortError(signal));
751
+ return;
752
+ }
753
+ const timer = setTimeout(() => {
754
+ signal?.removeEventListener("abort", onAbort);
755
+ resolve();
756
+ }, ms);
757
+ const onAbort = () => {
758
+ clearTimeout(timer);
759
+ reject(toAbortError(signal));
760
+ };
761
+ signal?.addEventListener("abort", onAbort, { once: true });
762
+ });
763
+ }
764
+ /**
765
+ * Concatenate streamed stdout/stderr chunks and decode them as UTF-8 text,
766
+ * reproducing the aggregated `stdout`/`stderr` strings the shell-backed
767
+ * {@link NodeRuntime.exec} path returns when a run is driven as a process for
768
+ * cancellation support.
769
+ */
770
+ function decodeChunks(chunks) {
771
+ if (chunks.length === 0) {
772
+ return "";
773
+ }
774
+ let total = 0;
775
+ for (const chunk of chunks) {
776
+ total += chunk.length;
777
+ }
778
+ const merged = new Uint8Array(total);
779
+ let offset = 0;
780
+ for (const chunk of chunks) {
781
+ merged.set(chunk, offset);
782
+ offset += chunk.length;
783
+ }
784
+ return new TextDecoder().decode(merged);
785
+ }
786
+ function toExecResult(result) {
787
+ return {
788
+ stdout: result.stdout,
789
+ stderr: result.stderr,
790
+ exitCode: result.exitCode,
791
+ };
792
+ }
793
+ /**
794
+ * Decode the raw JSON the kernel returns for a VM HTTP request into a
795
+ * structured response. The wire shape carries `status`, an optional
796
+ * `statusText`, `headers` (either an array of `[name, value]` pairs or an
797
+ * object), and a `body` that is base64-encoded when `bodyEncoding` is
798
+ * `"base64"`.
799
+ */
800
+ function parseFetchResponse(responseJson) {
801
+ const parsed = JSON.parse(responseJson);
802
+ const headers = {};
803
+ if (Array.isArray(parsed.headers)) {
804
+ for (const [name, value] of parsed.headers) {
805
+ headers[name.toLowerCase()] = value;
806
+ }
807
+ }
808
+ else if (parsed.headers) {
809
+ for (const [name, value] of Object.entries(parsed.headers)) {
810
+ headers[name.toLowerCase()] = value;
811
+ }
812
+ }
813
+ let body = parsed.body ?? "";
814
+ if (parsed.bodyEncoding === "base64" && body.length > 0) {
815
+ body = new TextDecoder().decode(Uint8Array.from(globalThis.atob(body), (char) => char.charCodeAt(0)));
816
+ }
817
+ return {
818
+ status: parsed.status ?? 0,
819
+ statusText: parsed.statusText ?? "",
820
+ headers,
821
+ body,
822
+ };
823
+ }