@melaya/runner 1.0.118 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }