@melaya/runner 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -332,54 +332,54 @@ async function interceptWebSockets(page, policy, hooks) {
332
332
  * IP leakage via STUN/TURN. Combined with the CDP Network.enable /
333
333
  * setRTCConfiguration override (applied separately via CDP below), this
334
334
  * provides layered defence. */
335
- const PAGE_GUARD_INIT_SCRIPT = `(function() {
336
- // WebRTC neutralisation: remove peer connection constructors.
337
- try { delete window.RTCPeerConnection; } catch (_) {}
338
- try { delete window.RTCSessionDescription; } catch (_) {}
339
- try { delete window.RTCIceCandidate; } catch (_) {}
340
- // Also zero out webkit/moz-prefixed variants some legacy code still uses.
341
- try { delete window.webkitRTCPeerConnection; } catch (_) {}
342
- try { delete window.mozRTCPeerConnection; } catch (_) {}
343
- // Override navigator.mediaDevices.getUserMedia to prevent media/camera
344
- // capture that could be combined with a data channel.
345
- try {
346
- if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
347
- Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
348
- }
349
- } catch (_) {}
350
- // WebSocket URL-blocking fallback (defence-in-depth, supplements
351
- // routeWebSocket interception which fires at the Playwright layer).
352
- var _NativeWS = window.WebSocket;
353
- function _isForbiddenWsHost(url) {
354
- try {
355
- var u = new URL(url);
356
- var h = u.hostname.replace(/^\\[|\\]$/g,'');
357
- // Loopback / localhost
358
- if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
359
- // Link-local (169.254.x.x) — cloud metadata endpoints live here
360
- if (/^169\\.254\\./.test(h)) return true;
361
- // RFC1918
362
- if (/^10\\./.test(h)) return true;
363
- if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
364
- if (/^192\\.168\\./.test(h)) return true;
365
- // CGNAT
366
- if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
367
- // Explicit metadata hosts
368
- if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
369
- } catch (_) {}
370
- return false;
371
- }
372
- window.WebSocket = function WrappedWebSocket(url, protocols) {
373
- if (_isForbiddenWsHost(String(url))) {
374
- throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
375
- }
376
- return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
377
- };
378
- window.WebSocket.prototype = _NativeWS.prototype;
379
- window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
380
- window.WebSocket.OPEN = _NativeWS.OPEN;
381
- window.WebSocket.CLOSING = _NativeWS.CLOSING;
382
- window.WebSocket.CLOSED = _NativeWS.CLOSED;
335
+ const PAGE_GUARD_INIT_SCRIPT = `(function() {
336
+ // WebRTC neutralisation: remove peer connection constructors.
337
+ try { delete window.RTCPeerConnection; } catch (_) {}
338
+ try { delete window.RTCSessionDescription; } catch (_) {}
339
+ try { delete window.RTCIceCandidate; } catch (_) {}
340
+ // Also zero out webkit/moz-prefixed variants some legacy code still uses.
341
+ try { delete window.webkitRTCPeerConnection; } catch (_) {}
342
+ try { delete window.mozRTCPeerConnection; } catch (_) {}
343
+ // Override navigator.mediaDevices.getUserMedia to prevent media/camera
344
+ // capture that could be combined with a data channel.
345
+ try {
346
+ if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
347
+ Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
348
+ }
349
+ } catch (_) {}
350
+ // WebSocket URL-blocking fallback (defence-in-depth, supplements
351
+ // routeWebSocket interception which fires at the Playwright layer).
352
+ var _NativeWS = window.WebSocket;
353
+ function _isForbiddenWsHost(url) {
354
+ try {
355
+ var u = new URL(url);
356
+ var h = u.hostname.replace(/^\\[|\\]$/g,'');
357
+ // Loopback / localhost
358
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
359
+ // Link-local (169.254.x.x) — cloud metadata endpoints live here
360
+ if (/^169\\.254\\./.test(h)) return true;
361
+ // RFC1918
362
+ if (/^10\\./.test(h)) return true;
363
+ if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
364
+ if (/^192\\.168\\./.test(h)) return true;
365
+ // CGNAT
366
+ if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
367
+ // Explicit metadata hosts
368
+ if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
369
+ } catch (_) {}
370
+ return false;
371
+ }
372
+ window.WebSocket = function WrappedWebSocket(url, protocols) {
373
+ if (_isForbiddenWsHost(String(url))) {
374
+ throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
375
+ }
376
+ return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
377
+ };
378
+ window.WebSocket.prototype = _NativeWS.prototype;
379
+ window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
380
+ window.WebSocket.OPEN = _NativeWS.OPEN;
381
+ window.WebSocket.CLOSING = _NativeWS.CLOSING;
382
+ window.WebSocket.CLOSED = _NativeWS.CLOSED;
383
383
  })();`;
384
384
  /** Disable WebRTC at the CDP Network domain level in addition to the
385
385
  * init-script layer (belt-and-suspenders). Silently skips if CDP
@@ -1,5 +1,5 @@
1
1
  import type { BrowserGrant } from "./browserGrantVerify.js";
2
- import { type SpaceSpec } from "./sessionManager.js";
2
+ import { type BrowserSessionRecord, type SpaceSpec } from "./sessionManager.js";
3
3
  import { type BrowserEngineId } from "./browserProvisioner.js";
4
4
  export interface BrowserRunSpec {
5
5
  runId: string;
@@ -24,6 +24,42 @@ export interface BrowserBridge {
24
24
  teardownRun(runId: string, reason: string): Promise<void>;
25
25
  teardownAll(reason: string): Promise<void>;
26
26
  hasRun(runId: string): boolean;
27
+ /**
28
+ * Launch a headed browser as an interactive (grant-free, read-only-watch)
29
+ * session tracked in sessionManager under `sessionId`.
30
+ *
31
+ * The session is governed by browserProvisioner for engine resolution
32
+ * and by sessionManager for lifecycle. It is discoverable by
33
+ * setWatchLease(sessionId, ...) and by getInteractiveSession(sessionId),
34
+ * which is the attach seam for the grant/run path.
35
+ *
36
+ * State transitions are reported via `onState(sessionId, state, engine)`:
37
+ * "active" - browser is open and ready to watch
38
+ * "closed" - browser was closed by the user or system
39
+ * "failed" - launch failed (engine not found, Playwright error, etc.)
40
+ *
41
+ * Errors during launch are caught internally; the caller receives
42
+ * "failed" via onState rather than a thrown exception.
43
+ */
44
+ launchInteractive(sessionId: string, engine: string, profile: string, onState: (sessionId: string, state: "active" | "closed" | "failed", engine: string) => void): Promise<void>;
45
+ /**
46
+ * Look up an active interactive session by the sessionId that was passed
47
+ * to launchInteractive. Returns the BrowserSessionRecord if the session
48
+ * is live, or undefined if it was never launched, has closed, or crashed.
49
+ *
50
+ * ATTACH SEAM: the grant/run path in ensureSession should call this
51
+ * before launching a new browser. If a record is returned the run can
52
+ * reuse rec.context and the existing page rather than opening a second
53
+ * browser for the same sessionId. Concretely, in ensureSession the
54
+ * "LAUNCH mode" block should check:
55
+ *
56
+ * const interactive = bridge.getInteractiveSession(spec.sessionId);
57
+ * if (interactive) { ... reuse interactive.context ... }
58
+ *
59
+ * This seam is left as a documented hook; full grant-attach wiring is
60
+ * a follow-up task once the grant schema carries a sessionId.
61
+ */
62
+ getInteractiveSession(sessionId: string): BrowserSessionRecord | undefined;
27
63
  /**
28
64
  * Set the watch-lease state for a session (plan Section 10.f).
29
65
  *
@@ -36,6 +72,10 @@ export interface BrowserBridge {
36
72
  *
37
73
  * The producer runs only while a viewer is watching and stops
38
74
  * automatically when the run is torn down.
75
+ *
76
+ * This method now finds both grant-scoped run sessions (looked up by
77
+ * runId) AND interactive sessions launched via launchInteractive
78
+ * (looked up by sessionId directly).
39
79
  */
40
80
  setWatchLease(sessionId: string, active: boolean, framePostUrl: string, runAuthHeader: string): void;
41
81
  shutdown(): Promise<void>;
@@ -82,6 +82,12 @@ export async function startBrowserBridge(opts) {
82
82
  // Per-lease transient state the sessionManager types stay clean of.
83
83
  const frameMaps = new WeakMap();
84
84
  const cdpByPage = new WeakMap();
85
+ // Interactive sessions: launched via launchInteractive(), tracked by the
86
+ // sessionId from the browser:launch socket event. The sessionManager record
87
+ // is stored here as well as in sessions (keyed by sessionId as runId) so
88
+ // getInteractiveSession and setWatchLease can find them without iterating
89
+ // the full grant-scoped run registry.
90
+ const interactiveSessions = new Map();
85
91
  let playwrightMod = null;
86
92
  async function pw() {
87
93
  if (!playwrightMod)
@@ -198,6 +204,105 @@ export async function startBrowserBridge(opts) {
198
204
  void disableWebRtcViaCdp(cdp);
199
205
  return cdp;
200
206
  }
207
+ // -- Interactive launch (browser:launch, no grant) ---------------------
208
+ //
209
+ // Opens a headed browser tracked in sessionManager so setWatchLease can
210
+ // find and stream frames from it. The session is READ-ONLY from the
211
+ // bridge's perspective (no CDP intercept effects, no grant gates). The
212
+ // sessionId from the socket event is used directly as the runId in the
213
+ // session manager so the setWatchLease lookup (which already checks
214
+ // `runId === sessionId`) finds the record without any additional mapping.
215
+ async function launchInteractive(sessionId, engine, profile, onState) {
216
+ // Normalise engine to a known BrowserEngineId; default to "chrome".
217
+ const knownEngines = new Set(["chrome", "edge", "brave", "chromium"]);
218
+ const engineId = (knownEngines.has(engine) ? engine : "chrome");
219
+ // Resolve profile directory, mirroring simpleBrowserLauncher semantics:
220
+ // "dedicated" => ephemeral per-session dir under tmpdir
221
+ // anything else => persistent named dir under ~/.melaya-runner/browser-profiles/
222
+ // spaceUserDataDir() already handles ephemeral vs persistent; map to SpaceSpec.
223
+ const spaceSpec = profile === "dedicated"
224
+ ? { kind: "ephemeral" }
225
+ : { kind: "persistent", id: `browser-profiles-${engineId}-${profile}` };
226
+ // Reject duplicate launches for the same sessionId.
227
+ if (interactiveSessions.has(sessionId)) {
228
+ log(`[browser-launch] sessionId=${sessionId.slice(0, 16)} already active; ignoring duplicate launch`);
229
+ return;
230
+ }
231
+ try {
232
+ const playwright = await pw();
233
+ const resolved = await ensureEngine(engineId, { log });
234
+ const { dir: userDataDir, ephemeral } = spaceUserDataDir(spaceSpec, sessionId);
235
+ // Create the session record BEFORE launch so teardownAll can find it
236
+ // even if the launch is still in progress.
237
+ const rec = sessions.createSession({
238
+ runId: sessionId,
239
+ ownership: "owned",
240
+ engine: engineId,
241
+ space: spaceSpec,
242
+ // Interactive sessions have a longer idle TTL (30 min) because the
243
+ // user may leave the browser open without actively using the runner.
244
+ idleTtlMs: 30 * 60 * 1000,
245
+ maxLifetimeMs: 4 * 60 * 60 * 1000,
246
+ });
247
+ interactiveSessions.set(sessionId, rec);
248
+ let context;
249
+ try {
250
+ context = await playwright.chromium.launchPersistentContext(userDataDir, {
251
+ executablePath: resolved.executablePath,
252
+ headless: false,
253
+ viewport: { width: 1280, height: 800 },
254
+ acceptDownloads: false,
255
+ args: [
256
+ "--no-first-run",
257
+ "--no-default-browser-check",
258
+ "--disable-background-networking",
259
+ "--disable-sync",
260
+ ],
261
+ });
262
+ }
263
+ catch (launchErr) {
264
+ // Clean up the session record we created before failing.
265
+ interactiveSessions.delete(sessionId);
266
+ await sessions.teardownRun(sessionId, "interactive_launch_failed").catch(() => { });
267
+ log(`[browser-launch] launch failed sessionId=${sessionId.slice(0, 16)}: ${launchErr?.message || launchErr}`);
268
+ onState(sessionId, "failed", engine);
269
+ return;
270
+ }
271
+ sessions.attachHandles(rec, {
272
+ context,
273
+ ephemeralUserDataDir: ephemeral ? userDataDir : null,
274
+ });
275
+ log(`[browser-launch] interactive session active sessionId=${sessionId.slice(0, 16)} engine=${engineId}`);
276
+ onState(sessionId, "active", engine);
277
+ // Detect browser close: context 'close' fires when the user shuts
278
+ // the browser window or when teardown closes it.
279
+ context.once("close", () => {
280
+ interactiveSessions.delete(sessionId);
281
+ // Only emit "closed" if the session was not already torn down by
282
+ // teardownAll/shutdown (rec.state will be "closed" in that case).
283
+ if (rec.state !== "closed") {
284
+ sessions.teardownRun(sessionId, "interactive_context_closed").catch(() => { });
285
+ log(`[browser-launch] interactive session closed sessionId=${sessionId.slice(0, 16)}`);
286
+ onState(sessionId, "closed", engine);
287
+ }
288
+ });
289
+ }
290
+ catch (err) {
291
+ interactiveSessions.delete(sessionId);
292
+ log(`[browser-launch] unexpected error sessionId=${sessionId.slice(0, 16)}: ${err?.message || err}`);
293
+ onState(sessionId, "failed", engine);
294
+ }
295
+ }
296
+ function getInteractiveSession(sessionId) {
297
+ const rec = interactiveSessions.get(sessionId);
298
+ if (!rec)
299
+ return undefined;
300
+ if (rec.state === "closed" || rec.state === "crashed") {
301
+ interactiveSessions.delete(sessionId);
302
+ return undefined;
303
+ }
304
+ return rec;
305
+ }
201
306
  async function captureSnapshot(rec, lease, scope) {
202
307
  const page = lease.page;
203
308
  const generation = sessions.beginSnapshot(lease);
@@ -708,19 +813,57 @@ export async function startBrowserBridge(opts) {
708
813
  async function captureAndSchedule(lease) {
709
814
  if (!lease.active)
710
815
  return;
711
- const reg = byRunId.get(lease.runId);
712
- if (!reg || reg.cancelled) {
713
- lease.active = false;
714
- return;
715
- }
716
816
  try {
717
817
  const rec = sessions.peekSession(lease.runId);
718
818
  if (!rec || rec.state === "crashed" || rec.state === "closed") {
719
819
  lease.active = false;
720
820
  return;
721
821
  }
722
- const target = sessions.getLease(rec, reg.spec.grant.target.ref);
723
- const { image_b64: jpegB64 } = await takeScreenshot(target);
822
+ // Resolve the target page. Interactive sessions (no grant, no registered
823
+ // run) use the first available page in the context. Grant-scoped run
824
+ // sessions use the leased target ref from the registration.
825
+ let targetLease = null;
826
+ const isInteractive = interactiveSessions.has(lease.runId) ||
827
+ (interactiveSessions.has(lease.sessionId) && lease.sessionId === lease.runId);
828
+ if (isInteractive) {
829
+ // Interactive: grab the first page in the context, or create one.
830
+ const context = rec.context;
831
+ if (!context) {
832
+ lease.active = false;
833
+ return;
834
+ }
835
+ let page = context.pages()[0];
836
+ if (!page) {
837
+ try {
838
+ page = await context.newPage();
839
+ }
840
+ catch {
841
+ lease.active = false;
842
+ return;
843
+ }
844
+ }
845
+ // Lease the page under a stable "interactive" ref. leaseTarget is
846
+ // idempotent (returns existing lease if the ref already exists).
847
+ targetLease = sessions.leaseTarget(rec, "interactive", page);
848
+ }
849
+ else {
850
+ const reg = byRunId.get(lease.runId);
851
+ if (!reg || reg.cancelled) {
852
+ lease.active = false;
853
+ return;
854
+ }
855
+ try {
856
+ targetLease = sessions.getLease(rec, reg.spec.grant.target.ref);
857
+ }
858
+ catch {
859
+ // Target lease not yet established (session still initialising) or gone.
860
+ if (lease.active) {
861
+ lease.timer = setTimeout(() => { void captureAndSchedule(lease); }, 1000);
862
+ }
863
+ return;
864
+ }
865
+ }
866
+ const { image_b64: jpegB64 } = await takeScreenshot(targetLease);
724
867
  const changed = jpegB64 !== lease.lastJpegB64;
725
868
  if (changed) {
726
869
  lease.lastJpegB64 = jpegB64;
@@ -757,24 +900,39 @@ export async function startBrowserBridge(opts) {
757
900
  stopWatchLease(sessionId);
758
901
  return;
759
902
  }
760
- // Find which runId corresponds to this sessionId (session ids map 1:1
761
- // to runIds in Phase 1 — one session per run).
903
+ // Find which runId corresponds to this sessionId.
904
+ // Priority order:
905
+ // 1. Interactive session: sessionId is both the key in interactiveSessions
906
+ // AND the runId used in sessionManager (so peek will find it directly).
907
+ // 2. Grant-scoped run session: iterate byRunId and check rec.id or runId.
762
908
  let matchedRunId = "";
763
- for (const [runId, reg] of byRunId) {
764
- const rec = sessions.peekSession(runId);
765
- if (rec && rec.id === sessionId) {
766
- matchedRunId = runId;
767
- break;
909
+ // Check interactive sessions first (launched via launchInteractive).
910
+ if (interactiveSessions.has(sessionId)) {
911
+ const rec = interactiveSessions.get(sessionId);
912
+ if (rec && rec.state !== "closed" && rec.state !== "crashed") {
913
+ matchedRunId = sessionId; // sessionId is used as runId for interactive sessions
768
914
  }
769
- // Also accept runId directly as sessionId (used when the server
770
- // addresses the watch by runId rather than the session UUID).
771
- if (runId === sessionId) {
772
- matchedRunId = runId;
773
- break;
915
+ }
916
+ // Fall back to grant-scoped run sessions (session ids map 1:1 to runIds
917
+ // in Phase 1, one session per run).
918
+ if (!matchedRunId) {
919
+ for (const [runId, reg] of byRunId) {
920
+ const rec = sessions.peekSession(runId);
921
+ if (rec && rec.id === sessionId) {
922
+ matchedRunId = runId;
923
+ break;
924
+ }
925
+ // Also accept runId directly as sessionId (used when the server
926
+ // addresses the watch by runId rather than the session UUID).
927
+ if (runId === sessionId) {
928
+ matchedRunId = runId;
929
+ break;
930
+ }
931
+ void reg; // suppress unused variable warning
774
932
  }
775
933
  }
776
934
  if (!matchedRunId) {
777
- log(`[watch] browser:watch active=true for unknown sessionId ${sessionId.slice(0, 16)} ignored`);
935
+ log(`[watch] browser:watch active=true for unknown sessionId ${sessionId.slice(0, 16)} - ignored`);
778
936
  return;
779
937
  }
780
938
  // Stop any existing lease for this session before starting a new one.
@@ -985,8 +1143,23 @@ export async function startBrowserBridge(opts) {
985
1143
  log(`browser run torn down: ${runId.slice(0, 10)} reason=${reason}`);
986
1144
  };
987
1145
  const teardownAll = async (reason) => {
988
- const ids = [...byRunId.keys()];
989
- await Promise.all(ids.map((id) => teardownRun(id, reason)));
1146
+ // Tear down grant-scoped run sessions.
1147
+ const runIds = [...byRunId.keys()];
1148
+ // Tear down interactive sessions (those not already covered by byRunId).
1149
+ const interactiveIds = [...interactiveSessions.keys()].filter((id) => !byRunId.has(id));
1150
+ await Promise.all([
1151
+ ...runIds.map((id) => teardownRun(id, reason)),
1152
+ ...interactiveIds.map(async (id) => {
1153
+ // Stop watch-lease producers for this interactive session.
1154
+ for (const [sid, wl] of watchLeases) {
1155
+ if (wl.runId === id)
1156
+ stopWatchLease(sid);
1157
+ }
1158
+ interactiveSessions.delete(id);
1159
+ await sessions.teardownRun(id, reason);
1160
+ log(`[browser-launch] interactive session torn down: ${id.slice(0, 16)} reason=${reason}`);
1161
+ }),
1162
+ ]);
990
1163
  };
991
1164
  return {
992
1165
  url: `http://127.0.0.1:${port}`,
@@ -997,6 +1170,8 @@ export async function startBrowserBridge(opts) {
997
1170
  hasRun(runId) {
998
1171
  return byRunId.has(runId);
999
1172
  },
1173
+ launchInteractive,
1174
+ getInteractiveSession,
1000
1175
  setWatchLease,
1001
1176
  async shutdown() {
1002
1177
  // Stop all watch-lease producers before closing sessions.
@@ -95,74 +95,74 @@ const WORKER_OP_ALLOWLIST = new Set([
95
95
  // boundary is the process + OS sandbox around it. The vm context exposes
96
96
  // exactly: browser.<op>(), console.log/warn/error, sleep(ms), and
97
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
- }
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
166
  `;
167
167
  function nodePermissionFlags(scratchDir, entryPath) {
168
168
  const major = Number(process.version.replace(/^v/, "").split(".")[0]) || 0;
@@ -295,6 +295,38 @@ export async function connect(opts) {
295
295
  console.log(chalk.gray(` [browser-bridge] watch lease sessionId=${sessionId.slice(0, 16)} active=${active}`));
296
296
  }
297
297
  });
298
+ // ── BrowserControlPage interactive browser launch (plan BrowserControlPage) ──
299
+ // The server emits browser:launch when the user clicks "Launch controllable
300
+ // browser" in the BrowserControlPage UI. Unlike runner:run browser grants,
301
+ // this is an unprivileged direct-launch (no grant, no CDP intercept) — the
302
+ // server just wants us to open a browser process the user can observe.
303
+ // State transitions are relayed back via browser:session so the server can
304
+ // update agents.browser_sessions.
305
+ socket.on("browser:launch", async (payload) => {
306
+ const sessionId = String(payload?.sessionId || "");
307
+ const engine = String(payload?.engine || "chrome");
308
+ const profile = String(payload?.profile || "dedicated");
309
+ if (!sessionId)
310
+ return;
311
+ if (opts.verbose) {
312
+ console.log(chalk.gray(` [browser-launch] sessionId=${sessionId.slice(0, 16)} engine=${engine} profile=${profile}`));
313
+ }
314
+ // Ensure the governed bridge is running before delegating.
315
+ const bridge = await _ensureBrowserBridge();
316
+ if (!bridge) {
317
+ console.log(chalk.yellow(` ! browser:launch: bridge unavailable for sessionId=${sessionId.slice(0, 16)}`));
318
+ socket.emit("browser:session", { sessionId, state: "failed", engine });
319
+ return;
320
+ }
321
+ // Delegate to the governed bridge. State transitions (active/closed/failed)
322
+ // are relayed back through the existing browser:session socket event.
323
+ await bridge.launchInteractive(sessionId, engine, profile, (sid, state, eng) => {
324
+ socket.emit("browser:session", { sessionId: sid, state, engine: eng });
325
+ if (opts.verbose) {
326
+ console.log(chalk.gray(` [browser-launch] browser:session sessionId=${sid.slice(0, 16)} state=${state}`));
327
+ }
328
+ });
329
+ });
298
330
  // ── Heartbeat ──────────────────────────────────────────────────────
299
331
  setInterval(() => {
300
332
  if (socket.connected)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,