@melaya/runner 1.0.117 → 1.1.0
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/dist/assistantHost.py +159 -13
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +50 -0
- package/dist/browserBridge.js +1021 -0
- package/dist/browserGrantVerify.d.ts +91 -0
- package/dist/browserGrantVerify.js +353 -0
- package/dist/browserProvisioner.d.ts +33 -0
- package/dist/browserProvisioner.js +266 -0
- package/dist/codeWorker.d.ts +49 -0
- package/dist/codeWorker.js +538 -0
- package/dist/connection.js +270 -3
- package/dist/sessionManager.d.ts +108 -0
- package/dist/sessionManager.js +290 -0
- package/package.json +4 -2
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// packages/runner/src/codeWorker.ts
|
|
2
|
+
//
|
|
3
|
+
// Melaya Browser, Phase 1/2 (plan Sections 0.7 and 9): OS-isolated code
|
|
4
|
+
// worker for model-authored JS ("code mode", the /browser/run endpoint).
|
|
5
|
+
//
|
|
6
|
+
// node:vm alone is NOT a security boundary (plan 0.7). The model script
|
|
7
|
+
// therefore runs in a SEPARATE child OS process with, per platform, the
|
|
8
|
+
// tightest sandbox the OS gives us without native modules:
|
|
9
|
+
//
|
|
10
|
+
// Linux: bubblewrap (`bwrap`) when installed: --unshare-all (new
|
|
11
|
+
// user/net/pid/ipc/uts/cgroup namespaces -> NO network, no
|
|
12
|
+
// host pids), read-only /usr /lib binds, the per-run scratch
|
|
13
|
+
// dir as the only writable path, --die-with-parent.
|
|
14
|
+
// Memory cap: bwrap rlimit (--rlimit AS=<bytes>) + Node
|
|
15
|
+
// --max-old-space-size flag enforced from outside the script.
|
|
16
|
+
// CPU cap: bwrap does not expose rlimit CPU directly; the
|
|
17
|
+
// parent wall-clock timer issues SIGKILL on breach.
|
|
18
|
+
// Fallback without bwrap: plain child + Node permission
|
|
19
|
+
// model (below); memory cap via --max-old-space-size + the
|
|
20
|
+
// wall-clock SIGKILL; network denial is then NOT OS-enforced
|
|
21
|
+
// (documented limitation surfaced in the result).
|
|
22
|
+
// macOS: sandbox-exec with a deny-default Seatbelt profile:
|
|
23
|
+
// (deny default), (deny network*), file reads restricted to
|
|
24
|
+
// Node runtime paths, file writes only under the scratch dir.
|
|
25
|
+
// Memory cap: `ulimit -v <kb>` shell wrapper around the
|
|
26
|
+
// sandbox-exec invocation, plus --max-old-space-size.
|
|
27
|
+
// CPU cap: `ulimit -t <seconds>` hard CPU-time limit in the
|
|
28
|
+
// same shell wrapper.
|
|
29
|
+
// sandbox-exec is deprecated by Apple but still functional
|
|
30
|
+
// and is what Chrome/Safari helpers use under the hood.
|
|
31
|
+
// HONEST LIMIT on macOS: ulimit -v caps virtual address
|
|
32
|
+
// space (not RSS) and can be hit before the real heap
|
|
33
|
+
// limit on some Node versions. Combined with --max-old-space
|
|
34
|
+
// the effective memory pressure is bounded.
|
|
35
|
+
// Windows: NO practical restricted-token/Job-Object API is reachable
|
|
36
|
+
// from pure Node without a native addon. The child gets: a
|
|
37
|
+
// sanitized environment (NO MEL_* / tokens / cloud creds),
|
|
38
|
+
// no shell, windowsHide, only stdio fds inherited, plus the
|
|
39
|
+
// Node permission model (fs restricted to the scratch dir,
|
|
40
|
+
// child-process + worker spawning denied), and
|
|
41
|
+
// --max-old-space-size as the heap cap. HONEST LIMIT:
|
|
42
|
+
// on Windows, direct outbound sockets from a vm-escaped
|
|
43
|
+
// script are NOT OS-blocked; compensating controls are the
|
|
44
|
+
// sanitized env (nothing to exfiltrate), the parent-side
|
|
45
|
+
// resource caps, and the facade-only IPC (no page/CDP/fetch
|
|
46
|
+
// handles ever cross the boundary). Job Object memory capping
|
|
47
|
+
// requires a native addon; the --max-old-space-size flag
|
|
48
|
+
// provides a partial compensating control.
|
|
49
|
+
//
|
|
50
|
+
// All platforms additionally get, on Node >= 20, the Node permission
|
|
51
|
+
// model (--experimental-permission / --permission): fs read/write
|
|
52
|
+
// limited to the scratch dir, child_process and worker_threads denied.
|
|
53
|
+
//
|
|
54
|
+
// The script sees ONLY a typed governed facade proxied over stdio IPC
|
|
55
|
+
// (newline-delimited JSON). Every facade op is validated, authorized
|
|
56
|
+
// (origin policy + effect ceiling) and traced BY THE PARENT before it
|
|
57
|
+
// touches the browser. Raw page / locator / cdp / fetch handles never
|
|
58
|
+
// exist inside the worker (plan Section 9).
|
|
59
|
+
//
|
|
60
|
+
// External resource enforcement lives in the PARENT (this module): wall
|
|
61
|
+
// clock, op count, per-op arg size, console line count, total output
|
|
62
|
+
// bytes, screenshot count, navigation count. Breach -> SIGKILL with
|
|
63
|
+
// kill verification (poll for exit; escalate; report kill_unverified if
|
|
64
|
+
// the OS will not confirm death).
|
|
65
|
+
import { spawn } from "node:child_process";
|
|
66
|
+
import { writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
67
|
+
import { join } from "node:path";
|
|
68
|
+
import { createInterface } from "node:readline";
|
|
69
|
+
export const DEFAULT_LIMITS = {
|
|
70
|
+
wallClockMs: 60_000,
|
|
71
|
+
maxOps: 40,
|
|
72
|
+
maxArgBytes: 32 * 1024,
|
|
73
|
+
maxConsoleLines: 200,
|
|
74
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
75
|
+
maxScreenshots: 4,
|
|
76
|
+
maxNavigations: 10,
|
|
77
|
+
maxMemoryMb: 256,
|
|
78
|
+
maxCpuSeconds: 60,
|
|
79
|
+
};
|
|
80
|
+
/** Ops the worker may request. Screenshot/navigation kinds get their own
|
|
81
|
+
* counters. Anything not listed is rejected without reaching the facade. */
|
|
82
|
+
const WORKER_OP_ALLOWLIST = new Set([
|
|
83
|
+
"current_target",
|
|
84
|
+
"get_screen_tree",
|
|
85
|
+
"screenshot",
|
|
86
|
+
"act",
|
|
87
|
+
"wait",
|
|
88
|
+
]);
|
|
89
|
+
// ---------------------------------------------------------------------
|
|
90
|
+
// Worker entry source (self-contained CJS written into the scratch dir)
|
|
91
|
+
// ---------------------------------------------------------------------
|
|
92
|
+
//
|
|
93
|
+
// The model script runs inside node:vm here purely as a CONTAINMENT AID
|
|
94
|
+
// (undefined globals, no require/process in scope) — the security
|
|
95
|
+
// boundary is the process + OS sandbox around it. The vm context exposes
|
|
96
|
+
// exactly: browser.<op>(), console.log/warn/error, sleep(ms), and
|
|
97
|
+
// JSON/Math/Date via the fresh realm.
|
|
98
|
+
const WORKER_ENTRY_SOURCE = `"use strict";
|
|
99
|
+
const vm = require("node:vm");
|
|
100
|
+
const readline = require("node:readline");
|
|
101
|
+
|
|
102
|
+
let seq = 0;
|
|
103
|
+
const pending = new Map();
|
|
104
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\\n"); }
|
|
105
|
+
|
|
106
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
107
|
+
let scriptSource = null;
|
|
108
|
+
rl.on("line", (line) => {
|
|
109
|
+
let m;
|
|
110
|
+
try { m = JSON.parse(line); } catch { return; }
|
|
111
|
+
if (m.type === "start" && typeof m.script === "string" && scriptSource === null) {
|
|
112
|
+
scriptSource = m.script;
|
|
113
|
+
run(scriptSource);
|
|
114
|
+
} else if (m.type === "op_result" && pending.has(m.id)) {
|
|
115
|
+
const { resolve, reject } = pending.get(m.id);
|
|
116
|
+
pending.delete(m.id);
|
|
117
|
+
if (m.ok) resolve(m.result);
|
|
118
|
+
else reject(new Error(m.error || "op failed"));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function facadeOp(op) {
|
|
123
|
+
return (args) => new Promise((resolve, reject) => {
|
|
124
|
+
const id = ++seq;
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
send({ type: "op", id, op, args: args && typeof args === "object" ? args : {} });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function run(source) {
|
|
131
|
+
const browser = Object.freeze({
|
|
132
|
+
currentTarget: facadeOp("current_target"),
|
|
133
|
+
getScreenTree: facadeOp("get_screen_tree"),
|
|
134
|
+
screenshot: facadeOp("screenshot"),
|
|
135
|
+
act: facadeOp("act"),
|
|
136
|
+
wait: facadeOp("wait"),
|
|
137
|
+
});
|
|
138
|
+
const consoleShim = {
|
|
139
|
+
log: (...a) => send({ type: "console", line: a.map(String).join(" ") }),
|
|
140
|
+
warn: (...a) => send({ type: "console", line: "[warn] " + a.map(String).join(" ") }),
|
|
141
|
+
error: (...a) => send({ type: "console", line: "[error] " + a.map(String).join(" ") }),
|
|
142
|
+
};
|
|
143
|
+
const sleep = (ms) => facadeOp("wait")({ ms });
|
|
144
|
+
const ctx = vm.createContext(Object.create(null), { codeGeneration: { strings: false, wasm: false } });
|
|
145
|
+
ctx.browser = browser;
|
|
146
|
+
ctx.console = consoleShim;
|
|
147
|
+
ctx.sleep = sleep;
|
|
148
|
+
ctx.JSON = JSON;
|
|
149
|
+
try {
|
|
150
|
+
const script = new vm.Script(
|
|
151
|
+
"(async () => {\\n" + source + "\\n})()",
|
|
152
|
+
{ filename: "model-script.js" },
|
|
153
|
+
);
|
|
154
|
+
const result = await script.runInContext(ctx, { timeout: 30000 });
|
|
155
|
+
send({ type: "done", result: safeJson(result) });
|
|
156
|
+
} catch (e) {
|
|
157
|
+
send({ type: "error", message: String((e && e.message) || e) });
|
|
158
|
+
}
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeJson(v) {
|
|
163
|
+
try { return JSON.parse(JSON.stringify(v === undefined ? null : v)); }
|
|
164
|
+
catch { return String(v); }
|
|
165
|
+
}
|
|
166
|
+
`;
|
|
167
|
+
function nodePermissionFlags(scratchDir, entryPath) {
|
|
168
|
+
const major = Number(process.version.replace(/^v/, "").split(".")[0]) || 0;
|
|
169
|
+
if (major < 20)
|
|
170
|
+
return [];
|
|
171
|
+
const flag = major >= 23 ? "--permission" : "--experimental-permission";
|
|
172
|
+
return [
|
|
173
|
+
flag,
|
|
174
|
+
`--allow-fs-read=${scratchDir}`,
|
|
175
|
+
`--allow-fs-read=${entryPath}`,
|
|
176
|
+
`--allow-fs-write=${scratchDir}`,
|
|
177
|
+
// NOT granted: --allow-child-process, --allow-worker, --allow-addons
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
function buildSandboxedCommand(scratchDir, entryPath, limits) {
|
|
181
|
+
const memMb = Math.min(Math.max(64, limits.maxMemoryMb), 512);
|
|
182
|
+
const cpuSec = Math.min(Math.max(10, limits.maxCpuSeconds), 300);
|
|
183
|
+
// --max-old-space-size caps the V8 heap on every platform.
|
|
184
|
+
const nodeArgs = [
|
|
185
|
+
`--max-old-space-size=${memMb}`,
|
|
186
|
+
...nodePermissionFlags(scratchDir, entryPath),
|
|
187
|
+
entryPath,
|
|
188
|
+
];
|
|
189
|
+
const nodeBin = process.execPath;
|
|
190
|
+
if (process.platform === "linux") {
|
|
191
|
+
const bwrap = ["/usr/bin/bwrap", "/usr/local/bin/bwrap"].find((p) => existsSync(p));
|
|
192
|
+
if (bwrap) {
|
|
193
|
+
const roBinds = [];
|
|
194
|
+
for (const p of ["/usr", "/lib", "/lib64", "/etc/ssl", "/etc/resolv.conf", "/bin", "/sbin", "/opt"]) {
|
|
195
|
+
if (existsSync(p))
|
|
196
|
+
roBinds.push("--ro-bind", p, p);
|
|
197
|
+
}
|
|
198
|
+
// Bind the node binary's own prefix in case it lives outside /usr
|
|
199
|
+
// (nvm installs under $HOME): read-only, binary only.
|
|
200
|
+
roBinds.push("--ro-bind", nodeBin, nodeBin);
|
|
201
|
+
// RLIMIT_AS caps virtual address space in bytes. bwrap expects two
|
|
202
|
+
// separate tokens: "--rlimit" "RLIMIT_AS=SOFT:HARD". Setting soft
|
|
203
|
+
// and hard to the same value makes it a hard cap. 4x the V8 heap
|
|
204
|
+
// limit gives the JS runtime enough headroom for compiled code,
|
|
205
|
+
// mapped shared libraries, and stack without being so generous
|
|
206
|
+
// that a leak can thrash the host.
|
|
207
|
+
// HONEST LIMIT: bwrap versions before ~0.4 may not support
|
|
208
|
+
// --rlimit. The spawn call will throw ENOENT/EINVAL if the bwrap
|
|
209
|
+
// binary is too old; the parent catches that and reports
|
|
210
|
+
// worker_spawn_failed. Operators on older distros should upgrade
|
|
211
|
+
// bubblewrap (typically available as `bubblewrap` or `bwrap` in
|
|
212
|
+
// the distro package manager).
|
|
213
|
+
const asBytes = memMb * 4 * 1024 * 1024;
|
|
214
|
+
return {
|
|
215
|
+
cmd: bwrap,
|
|
216
|
+
args: [
|
|
217
|
+
"--unshare-all", // includes --unshare-net: NO network
|
|
218
|
+
"--die-with-parent",
|
|
219
|
+
"--new-session",
|
|
220
|
+
"--proc", "/proc",
|
|
221
|
+
"--dev", "/dev",
|
|
222
|
+
"--tmpfs", "/tmp",
|
|
223
|
+
...roBinds,
|
|
224
|
+
"--bind", scratchDir, scratchDir,
|
|
225
|
+
"--chdir", scratchDir,
|
|
226
|
+
"--setenv", "HOME", scratchDir,
|
|
227
|
+
// bwrap --rlimit syntax: RESOURCE_NAME=SOFT:HARD
|
|
228
|
+
"--rlimit", `RLIMIT_AS=${asBytes}:${asBytes}`,
|
|
229
|
+
nodeBin,
|
|
230
|
+
...nodeArgs,
|
|
231
|
+
],
|
|
232
|
+
sandbox: "bubblewrap",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return { cmd: nodeBin, args: nodeArgs, sandbox: "node-permission-only" };
|
|
236
|
+
}
|
|
237
|
+
if (process.platform === "darwin") {
|
|
238
|
+
if (existsSync("/usr/bin/sandbox-exec")) {
|
|
239
|
+
// Deny-default Seatbelt profile (hardened from the previous version):
|
|
240
|
+
// deny default -- deny everything not explicitly allowed.
|
|
241
|
+
// deny network* -- no outbound or inbound connections.
|
|
242
|
+
// deny file-read* -- then re-allow only what Node needs.
|
|
243
|
+
// deny file-write* -- then re-allow only the scratch dir.
|
|
244
|
+
//
|
|
245
|
+
// File-read allowances for the node runtime: the binary itself, the
|
|
246
|
+
// dyld shared cache (macOS system library mmap), /System/Library for
|
|
247
|
+
// Foundation and CoreFoundation, /usr/lib for libSystem, and
|
|
248
|
+
// /private/var/folders/* for the OS's temp-file area (used by Node
|
|
249
|
+
// internally). The scratch dir is allowed for both read and write.
|
|
250
|
+
//
|
|
251
|
+
// HONEST LIMIT: sandbox-exec is deprecated (Apple may remove it in a
|
|
252
|
+
// future macOS) but remains functional on all current macOS releases.
|
|
253
|
+
// The node dyld shared cache path varies by macOS version; the
|
|
254
|
+
// /System/Volumes/Preboot allowance covers macOS 13+.
|
|
255
|
+
const nodeDir = nodeBin.replace(/\/[^/]+$/, "");
|
|
256
|
+
const profile = [
|
|
257
|
+
"(version 1)",
|
|
258
|
+
"(deny default)",
|
|
259
|
+
"(deny network*)",
|
|
260
|
+
// Allow the node binary and its directory (npm installs siblings).
|
|
261
|
+
`(allow file-read* (literal "${nodeBin}"))`,
|
|
262
|
+
`(allow file-read* (subpath "${nodeDir}"))`,
|
|
263
|
+
// Node's own scratch dir: full read/write.
|
|
264
|
+
`(allow file-read* (subpath "${scratchDir}"))`,
|
|
265
|
+
`(allow file-write* (subpath "${scratchDir}"))`,
|
|
266
|
+
// macOS system libraries and dyld shared cache.
|
|
267
|
+
"(allow file-read* (subpath \"/System/Library\"))",
|
|
268
|
+
"(allow file-read* (subpath \"/System/Volumes\"))",
|
|
269
|
+
"(allow file-read* (subpath \"/usr/lib\"))",
|
|
270
|
+
"(allow file-read* (subpath \"/usr/local/lib\"))",
|
|
271
|
+
"(allow file-read* (literal \"/dev/null\"))",
|
|
272
|
+
"(allow file-write* (literal \"/dev/null\"))",
|
|
273
|
+
"(allow file-read* (literal \"/dev/urandom\"))",
|
|
274
|
+
"(allow file-read* (literal \"/dev/random\"))",
|
|
275
|
+
// OS-managed temp area (Node uses this for some internal paths).
|
|
276
|
+
"(allow file-read* (subpath \"/private/var/folders\"))",
|
|
277
|
+
"(allow file-write* (subpath \"/private/var/folders\"))",
|
|
278
|
+
// process and mach primitives needed by dyld and the Node runtime.
|
|
279
|
+
"(allow process-exec (literal \"/usr/bin/env\"))",
|
|
280
|
+
`(allow process-exec (literal "${nodeBin}"))`,
|
|
281
|
+
"(allow sysctl-read)",
|
|
282
|
+
"(allow mach-lookup)",
|
|
283
|
+
"(allow ipc-posix-shm-read-data)",
|
|
284
|
+
"(allow ipc-posix-shm-write-data)",
|
|
285
|
+
].join("\n");
|
|
286
|
+
// Memory and CPU caps via a shell `ulimit` wrapper around sandbox-exec.
|
|
287
|
+
// ulimit -v: virtual address space in KB (4x heap for runtime headroom).
|
|
288
|
+
// ulimit -t: hard CPU time in seconds.
|
|
289
|
+
// We use /bin/sh -c "ulimit ...; exec sandbox-exec ..." so both limits
|
|
290
|
+
// apply before the Node process starts. This is a soft-then-hard pair;
|
|
291
|
+
// SIGKILL from the parent wall-clock timer is the backstop.
|
|
292
|
+
const asKb = memMb * 4 * 1024;
|
|
293
|
+
const shellCmd = `ulimit -v ${asKb} -t ${cpuSec}; exec /usr/bin/sandbox-exec -p '${profile.replace(/'/g, "'\\''")}' ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
|
|
294
|
+
return {
|
|
295
|
+
cmd: "/bin/sh",
|
|
296
|
+
args: ["-c", shellCmd],
|
|
297
|
+
sandbox: "seatbelt",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// No sandbox-exec: fall back to permission model + ulimit wrapper.
|
|
301
|
+
const asKb = memMb * 4 * 1024;
|
|
302
|
+
const shellCmd = `ulimit -v ${asKb} -t ${cpuSec}; exec ${JSON.stringify(nodeBin)} ${nodeArgs.map((a) => JSON.stringify(a)).join(" ")}`;
|
|
303
|
+
return {
|
|
304
|
+
cmd: "/bin/sh",
|
|
305
|
+
args: ["-c", shellCmd],
|
|
306
|
+
sandbox: "node-permission-only",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Windows: sanitized env + permission model only (see header).
|
|
310
|
+
// --max-old-space-size (already in nodeArgs) is the only heap cap
|
|
311
|
+
// without a native addon. HONEST LIMIT: OS-level memory enforcement
|
|
312
|
+
// requires a Job Object, which is not reachable from pure Node.
|
|
313
|
+
const major = Number(process.version.replace(/^v/, "").split(".")[0]) || 0;
|
|
314
|
+
return {
|
|
315
|
+
cmd: nodeBin,
|
|
316
|
+
args: nodeArgs,
|
|
317
|
+
sandbox: major >= 20 ? "node-permission-only" : "none",
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** Minimal, secret-free environment. NO MEL_* names, no tokens, no
|
|
321
|
+
* inherited operator env. Windows needs SystemRoot for node to boot. */
|
|
322
|
+
function sanitizedEnv(scratchDir) {
|
|
323
|
+
const env = {
|
|
324
|
+
TMPDIR: scratchDir,
|
|
325
|
+
TEMP: scratchDir,
|
|
326
|
+
TMP: scratchDir,
|
|
327
|
+
HOME: scratchDir,
|
|
328
|
+
USERPROFILE: scratchDir,
|
|
329
|
+
NODE_OPTIONS: "",
|
|
330
|
+
NO_COLOR: "1",
|
|
331
|
+
};
|
|
332
|
+
if (process.platform === "win32") {
|
|
333
|
+
env["SystemRoot"] = process.env["SystemRoot"] || "C:\\Windows";
|
|
334
|
+
env["PATH"] = env["SystemRoot"] + "\\System32";
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
env["PATH"] = "/usr/bin:/bin";
|
|
338
|
+
}
|
|
339
|
+
return env;
|
|
340
|
+
}
|
|
341
|
+
// ---------------------------------------------------------------------
|
|
342
|
+
// Runner
|
|
343
|
+
// ---------------------------------------------------------------------
|
|
344
|
+
export async function runSandboxedScript(opts) {
|
|
345
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
346
|
+
limits.wallClockMs = Math.min(limits.wallClockMs, 180_000);
|
|
347
|
+
const log = opts.log ?? (() => { });
|
|
348
|
+
const consoleLines = [];
|
|
349
|
+
const traces = [];
|
|
350
|
+
mkdirSync(opts.scratchDir, { recursive: true });
|
|
351
|
+
const entryPath = join(opts.scratchDir, "melaya-code-worker.cjs");
|
|
352
|
+
writeFileSync(entryPath, WORKER_ENTRY_SOURCE, "utf-8");
|
|
353
|
+
const { cmd, args, sandbox } = buildSandboxedCommand(opts.scratchDir, entryPath, limits);
|
|
354
|
+
let child;
|
|
355
|
+
try {
|
|
356
|
+
child = spawn(cmd, args, {
|
|
357
|
+
cwd: opts.scratchDir,
|
|
358
|
+
env: sanitizedEnv(opts.scratchDir),
|
|
359
|
+
// ONLY stdio pipes are inherited; Node does not pass extra fds
|
|
360
|
+
// unless they are listed here, which closes the inherited-fd
|
|
361
|
+
// channel (plan 0.7).
|
|
362
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
363
|
+
windowsHide: true,
|
|
364
|
+
detached: false,
|
|
365
|
+
shell: false,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false, console: [], traces, sandbox, killVerified: true,
|
|
371
|
+
error: { code: "worker_spawn_failed", message: String(e?.message || e) },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
let opCount = 0;
|
|
375
|
+
let screenshotCount = 0;
|
|
376
|
+
let navCount = 0;
|
|
377
|
+
let outputBytes = 0;
|
|
378
|
+
let settled = false;
|
|
379
|
+
let killVerified = true;
|
|
380
|
+
let breach = null;
|
|
381
|
+
const result = await new Promise((resolve) => {
|
|
382
|
+
const finish = (r) => {
|
|
383
|
+
if (settled)
|
|
384
|
+
return;
|
|
385
|
+
settled = true;
|
|
386
|
+
clearTimeout(wallTimer);
|
|
387
|
+
void killAndVerify();
|
|
388
|
+
resolve({ ...r, console: consoleLines, traces, sandbox, killVerified });
|
|
389
|
+
};
|
|
390
|
+
const killAndVerify = async () => {
|
|
391
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
392
|
+
return;
|
|
393
|
+
try {
|
|
394
|
+
child.kill();
|
|
395
|
+
}
|
|
396
|
+
catch { /* already gone */ }
|
|
397
|
+
await delay(1500);
|
|
398
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
399
|
+
try {
|
|
400
|
+
child.kill("SIGKILL");
|
|
401
|
+
}
|
|
402
|
+
catch { /* already gone */ }
|
|
403
|
+
await delay(1500);
|
|
404
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
405
|
+
killVerified = false;
|
|
406
|
+
log(`code worker pid=${child.pid} did not confirm death after SIGKILL`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const wallTimer = setTimeout(() => {
|
|
411
|
+
breach = { code: "wall_clock_exceeded", message: `script exceeded ${limits.wallClockMs}ms wall clock` };
|
|
412
|
+
finish({ ok: false, error: breach });
|
|
413
|
+
}, limits.wallClockMs);
|
|
414
|
+
child.on("error", (e) => {
|
|
415
|
+
finish({ ok: false, error: { code: "worker_error", message: e.message } });
|
|
416
|
+
});
|
|
417
|
+
child.on("exit", (code, signal) => {
|
|
418
|
+
if (!settled) {
|
|
419
|
+
finish({
|
|
420
|
+
ok: false,
|
|
421
|
+
error: { code: "worker_exited", message: `worker exited early (code=${code}, signal=${signal}) without a result` },
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
child.stderr?.on("data", (d) => {
|
|
426
|
+
outputBytes += d.length;
|
|
427
|
+
// Permission-model warnings etc. — keep for verbose diagnosis only.
|
|
428
|
+
log(`[code-worker stderr] ${d.toString().trim().slice(0, 500)}`);
|
|
429
|
+
});
|
|
430
|
+
const rl = createInterface({ input: child.stdout, terminal: false });
|
|
431
|
+
rl.on("line", (line) => {
|
|
432
|
+
outputBytes += Buffer.byteLength(line);
|
|
433
|
+
if (outputBytes > limits.maxOutputBytes) {
|
|
434
|
+
breach = { code: "output_cap_exceeded", message: `worker output exceeded ${limits.maxOutputBytes} bytes` };
|
|
435
|
+
finish({ ok: false, error: breach });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let msg;
|
|
439
|
+
try {
|
|
440
|
+
msg = JSON.parse(line);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (msg.type === "console") {
|
|
446
|
+
if (consoleLines.length < limits.maxConsoleLines) {
|
|
447
|
+
consoleLines.push(String(msg.line ?? "").slice(0, 2000));
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (msg.type === "done") {
|
|
452
|
+
finish({ ok: true, result: msg.result });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (msg.type === "error") {
|
|
456
|
+
finish({ ok: false, error: { code: "script_error", message: String(msg.message ?? "script failed").slice(0, 2000) } });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (msg.type === "op") {
|
|
460
|
+
void handleOp(msg);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
const handleOp = async (msg) => {
|
|
465
|
+
const id = Number(msg.id);
|
|
466
|
+
const op = String(msg.op ?? "");
|
|
467
|
+
const args = (msg.args && typeof msg.args === "object" ? msg.args : {});
|
|
468
|
+
const reply = (ok, resultOrError) => {
|
|
469
|
+
try {
|
|
470
|
+
child.stdin?.write(JSON.stringify(ok ? { type: "op_result", id, ok: true, result: resultOrError }
|
|
471
|
+
: { type: "op_result", id, ok: false, error: String(resultOrError) }) + "\n");
|
|
472
|
+
}
|
|
473
|
+
catch { /* child died; exit handler resolves */ }
|
|
474
|
+
};
|
|
475
|
+
// Parent-side enforcement gates (op count, allowlist, arg size,
|
|
476
|
+
// per-kind counters) BEFORE the facade ever sees the op.
|
|
477
|
+
opCount += 1;
|
|
478
|
+
if (opCount > limits.maxOps) {
|
|
479
|
+
breach = { code: "op_cap_exceeded", message: `script exceeded ${limits.maxOps} governed ops` };
|
|
480
|
+
finish({ ok: false, error: breach });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!WORKER_OP_ALLOWLIST.has(op)) {
|
|
484
|
+
reply(false, `op '${op}' is not in the worker allowlist`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (Buffer.byteLength(JSON.stringify(args)) > limits.maxArgBytes) {
|
|
488
|
+
reply(false, `op args exceed ${limits.maxArgBytes} bytes`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (op === "screenshot") {
|
|
492
|
+
screenshotCount += 1;
|
|
493
|
+
if (screenshotCount > limits.maxScreenshots) {
|
|
494
|
+
reply(false, `screenshot cap (${limits.maxScreenshots}) exceeded`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (op === "act" && String(args["kind"] ?? "") === "navigate") {
|
|
499
|
+
navCount += 1;
|
|
500
|
+
if (navCount > limits.maxNavigations) {
|
|
501
|
+
reply(false, `navigation cap (${limits.maxNavigations}) exceeded`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (op === "wait") {
|
|
506
|
+
const ms = Math.min(Math.max(0, Number(args["ms"] ?? 0)), 10_000);
|
|
507
|
+
args["ms"] = ms;
|
|
508
|
+
}
|
|
509
|
+
const started = Date.now();
|
|
510
|
+
const seq = traces.length + 1;
|
|
511
|
+
try {
|
|
512
|
+
const out = await opts.facade(op, args);
|
|
513
|
+
const t = { seq, op, ok: true, ms: Date.now() - started };
|
|
514
|
+
traces.push(t);
|
|
515
|
+
opts.onTrace?.(t);
|
|
516
|
+
reply(true, out);
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
const t = { seq, op, ok: false, ms: Date.now() - started, error: String(e?.message || e).slice(0, 500) };
|
|
520
|
+
traces.push(t);
|
|
521
|
+
opts.onTrace?.(t);
|
|
522
|
+
reply(false, t.error);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
// Ship the script AFTER handlers are wired. The script goes over
|
|
526
|
+
// stdin — never argv (visible in process lists) or env.
|
|
527
|
+
try {
|
|
528
|
+
child.stdin?.write(JSON.stringify({ type: "start", script: opts.script }) + "\n");
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
finish({ ok: false, error: { code: "worker_write_failed", message: String(e?.message || e) } });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
function delay(ms) {
|
|
537
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
538
|
+
}
|