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