@melaya/runner 1.1.23 → 1.1.24

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.
@@ -719,6 +719,12 @@ def _build_agent():
719
719
  max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
720
720
  else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
721
721
  else (40 if connector_services else 8))),
722
+ # Driving a device IS the long-horizon shape: read the screen, do one
723
+ # thing, read again. Without this the codex latency cap silently cut the
724
+ # 300 above to 3 — one click and a look — and every codex browser turn
725
+ # ended mid-task claiming an "iteration limit". Connector and plain
726
+ # chat turns keep the cap: they are not act-and-observe.
727
+ long_horizon=bool(phone_enabled or browser_enabled),
722
728
  reliability=True,
723
729
  bounded_memory=True,
724
730
  )
@@ -1,5 +1,5 @@
1
1
  import type { BrowserGrant } from "./browserGrantVerify.js";
2
- export type AuthzDenyCode = "scheme_forbidden" | "browser_internal_page" | "host_forbidden" | "private_network" | "metadata_endpoint" | "port_forbidden" | "dns_rebind" | "origin_not_in_scope" | "effect_not_granted" | "effect_over_ceiling" | "policy_unavailable";
2
+ export type AuthzDenyCode = "scheme_forbidden" | "browser_internal_page" | "host_forbidden" | "private_network" | "metadata_endpoint" | "port_forbidden" | "dns_rebind" | "origin_not_in_scope" | "effect_not_granted" | "effect_over_ceiling" | "policy_unavailable" | "run_cancelled";
3
3
  export type AuthzDecision = {
4
4
  allowed: true;
5
5
  } | {
@@ -59,6 +59,27 @@ export interface EnforcementHooks {
59
59
  * init-script layer (belt-and-suspenders). Silently skips if CDP
60
60
  * session creation fails (e.g. attached browser, same-process frame). */
61
61
  export declare function disableWebRtcViaCdp(cdpSession: unknown): Promise<void>;
62
+ /** What an enforcement installation hands back so it can be REMOVED again.
63
+ *
64
+ * Enforcement used to be install-only, and on an owned browser that was fine:
65
+ * teardown closes the whole context, handler included. On the ATTACH and
66
+ * INTERACTIVE paths it was not, because those deliberately leave the user's
67
+ * browser open after the turn ends. The route handler closed over the run's
68
+ * registration, `reg.cancelled` went true at teardown and stayed true, and the
69
+ * handler stayed installed on a page the user was still using — so every
70
+ * request on that tab, forever after, hit the cancel branch and aborted.
71
+ * Chrome renders that abort as `(blocked:devtools)`, which reads like a
72
+ * DevTools or anti-bot problem and is neither.
73
+ *
74
+ * Worse, each new run added ANOTHER handler to the same page. The newest one
75
+ * wins while its run is live, so the symptom only appeared once a run ended:
76
+ * the tab the agent had touched was bricked, and nothing said why.
77
+ *
78
+ * Disposal is idempotent and never throws: a page or context that has already
79
+ * closed is the normal case at teardown, not an error. */
80
+ export interface EnforcementHandle {
81
+ dispose: () => Promise<void>;
82
+ }
62
83
  /** Install request-level enforcement on a BrowserContext we OWN (launch
63
84
  * mode). Every document, subresource, XHR, redirect hop, and worker
64
85
  * script fetch flows through here; disallowed ones are aborted.
@@ -66,7 +87,7 @@ export declare function disableWebRtcViaCdp(cdpSession: unknown): Promise<void>;
66
87
  * WebSocket connections are intercepted via routeWebSocket (Playwright
67
88
  * 1.48+) with a page-init-script fallback for older builds.
68
89
  * WebRTC is neutralised via an init script on every new page. */
69
- export declare function enforceOnContext(context: PWBrowserContext, policy: OriginPolicy, hooks: EnforcementHooks): Promise<void>;
90
+ export declare function enforceOnContext(context: PWBrowserContext, policy: OriginPolicy, hooks: EnforcementHooks): Promise<EnforcementHandle>;
70
91
  /** Install enforcement on a SINGLE page (attach mode: we never take over
71
92
  * routing for the user's whole externally-owned browser context, only
72
93
  * the leased target the run operates on; navigation of the leased page
@@ -74,5 +95,5 @@ export declare function enforceOnContext(context: PWBrowserContext, policy: Orig
74
95
  * WebSocket interception and WebRTC neutralisation are applied to this
75
96
  * page and its popups in the same way as the owned-context path, scoped
76
97
  * to the single leased target. */
77
- export declare function enforceOnPage(page: PWPage, policy: OriginPolicy, hooks: EnforcementHooks): Promise<void>;
98
+ export declare function enforceOnPage(page: PWPage, policy: OriginPolicy, hooks: EnforcementHooks): Promise<EnforcementHandle>;
78
99
  export {};
@@ -399,17 +399,23 @@ export async function disableWebRtcViaCdp(cdpSession) {
399
399
  // CDP domain unavailable on this browser build — init script alone.
400
400
  }
401
401
  }
402
- /** Install request-level enforcement on a BrowserContext we OWN (launch
403
- * mode). Every document, subresource, XHR, redirect hop, and worker
404
- * script fetch flows through here; disallowed ones are aborted.
405
- * Downloads are default-denied via the page download handler.
406
- * WebSocket connections are intercepted via routeWebSocket (Playwright
407
- * 1.48+) with a page-init-script fallback for older builds.
408
- * WebRTC is neutralised via an init script on every new page. */
409
- export async function enforceOnContext(context, policy, hooks) {
410
- await context.route("**/*", async (route) => {
402
+ /** The one request gate, shared by the context-wide and per-page installs so
403
+ * the two can never drift in what they allow. */
404
+ function makeRouteHandler(policy, hooks, surface) {
405
+ return async (route) => {
411
406
  const url = route.request().url();
412
407
  if (hooks.isCancelled?.()) {
408
+ // REPORTED, not silent. This branch aborting quietly is exactly what
409
+ // made a stale handler on a live page undiagnosable: no violation, no
410
+ // log, just a dead tab. If this fires after teardown the dispose call
411
+ // was missed and the message says so.
412
+ hooks.onViolation({
413
+ url,
414
+ code: "run_cancelled",
415
+ message: "the run that installed this enforcement is cancelled or torn down; if the run is already " +
416
+ "over, its EnforcementHandle was not disposed and this page is being blocked by a stale handler",
417
+ surface,
418
+ });
413
419
  await route.abort("blockedbyclient").catch(() => { });
414
420
  return;
415
421
  }
@@ -418,10 +424,21 @@ export async function enforceOnContext(context, policy, hooks) {
418
424
  await route.continue().catch(() => { });
419
425
  }
420
426
  else {
421
- hooks.onViolation({ url, code: d.code, message: d.message, surface: "context_route" });
427
+ hooks.onViolation({ url, code: d.code, message: d.message, surface });
422
428
  await route.abort("blockedbyclient").catch(() => { });
423
429
  }
424
- });
430
+ };
431
+ }
432
+ /** Install request-level enforcement on a BrowserContext we OWN (launch
433
+ * mode). Every document, subresource, XHR, redirect hop, and worker
434
+ * script fetch flows through here; disallowed ones are aborted.
435
+ * Downloads are default-denied via the page download handler.
436
+ * WebSocket connections are intercepted via routeWebSocket (Playwright
437
+ * 1.48+) with a page-init-script fallback for older builds.
438
+ * WebRTC is neutralised via an init script on every new page. */
439
+ export async function enforceOnContext(context, policy, hooks) {
440
+ const handler = makeRouteHandler(policy, hooks, "context_route");
441
+ await context.route("**/*", handler);
425
442
  // Inject the WebSocket + WebRTC guard script into every new document
426
443
  // BEFORE page JS runs.
427
444
  await context.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
@@ -429,6 +446,7 @@ export async function enforceOnContext(context, policy, hooks) {
429
446
  void interceptWebSockets(page, policy, hooks);
430
447
  void guardPage(page, policy, hooks, /*closeOnDeny*/ true);
431
448
  });
449
+ return { dispose: () => context.unroute("**/*", handler).catch(() => { }) };
432
450
  }
433
451
  /** Install enforcement on a SINGLE page (attach mode: we never take over
434
452
  * routing for the user's whole externally-owned browser context, only
@@ -438,21 +456,8 @@ export async function enforceOnContext(context, policy, hooks) {
438
456
  * page and its popups in the same way as the owned-context path, scoped
439
457
  * to the single leased target. */
440
458
  export async function enforceOnPage(page, policy, hooks) {
441
- await page.route("**/*", async (route) => {
442
- const url = route.request().url();
443
- if (hooks.isCancelled?.()) {
444
- await route.abort("blockedbyclient").catch(() => { });
445
- return;
446
- }
447
- const d = await evaluateUrlResolved(url, policy);
448
- if (d.allowed) {
449
- await route.continue().catch(() => { });
450
- }
451
- else {
452
- hooks.onViolation({ url, code: d.code, message: d.message, surface: "page_route" });
453
- await route.abort("blockedbyclient").catch(() => { });
454
- }
455
- });
459
+ const handler = makeRouteHandler(policy, hooks, "page_route");
460
+ await page.route("**/*", handler);
456
461
  // WebSocket + WebRTC for the leased page.
457
462
  await page.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
458
463
  await interceptWebSockets(page, policy, hooks);
@@ -462,6 +467,7 @@ export async function enforceOnPage(page, policy, hooks) {
462
467
  void interceptWebSockets(popup, policy, hooks);
463
468
  void guardPage(popup, policy, hooks, /*closeOnDeny*/ true);
464
469
  });
470
+ return { dispose: () => page.unroute("**/*", handler).catch(() => { }) };
465
471
  }
466
472
  /** CDP-target-creation gate: when a page/popup materializes, verify its
467
473
  * destination; deny -> close before the agent can observe or act on it.
@@ -122,9 +122,33 @@ const _lastMouse = new WeakMap();
122
122
  async function humanPause(page, min = 40, max = 140) {
123
123
  await page.waitForTimeout(_randInt(min, max));
124
124
  }
125
+ /** The page's REAL viewport in CSS px.
126
+ *
127
+ * page.viewportSize() returns null whenever the context was launched with
128
+ * `viewport: null` — which is what a HEADED browser has to use (see the launch
129
+ * sites). Falling back to a constant there would be worse than the bug it
130
+ * fixes: resolveActionPoint turns 0..1 fractions into pixels with this, so a
131
+ * 0.5 fraction on a 2560px-wide window would resolve to 640px and every
132
+ * vision-guided click would land in the wrong place, silently.
133
+ *
134
+ * So measure the window instead. The constant survives only as the last
135
+ * resort for a page that cannot evaluate at all (crashed, closed, mid-swap). */
136
+ async function liveViewport(page) {
137
+ const vp = page.viewportSize();
138
+ if (vp && vp.width > 0 && vp.height > 0)
139
+ return vp;
140
+ try {
141
+ const m = (await page.evaluate("({ width: window.innerWidth, height: window.innerHeight })"));
142
+ const w = Number(m?.width), h = Number(m?.height);
143
+ if (w > 0 && h > 0)
144
+ return { width: w, height: h };
145
+ }
146
+ catch { /* page unavailable — fall through */ }
147
+ return { width: 1280, height: 800 };
148
+ }
125
149
  // Eased, jittered pointer travel from the last known position to (tx,ty).
126
150
  async function humanMove(page, tx, ty) {
127
- const vp = page.viewportSize() ?? { width: 1280, height: 800 };
151
+ const vp = await liveViewport(page);
128
152
  const from = _lastMouse.get(page) ?? { x: _rand(0, vp.width), y: _rand(0, vp.height) };
129
153
  const dist = Math.hypot(tx - from.x, ty - from.y);
130
154
  const steps = Math.max(6, Math.min(42, Math.round(dist / _rand(18, 34))));
@@ -224,13 +248,136 @@ const KIND_MIN_EFFECT = {
224
248
  get_text: "read", // read-only page text extraction
225
249
  wait_for_network_idle: "read", // read-only network wait
226
250
  ask_user: "read", // human takeover request; handled before any page op
251
+ // These three are advertised to the agent (browser_submit / browser_batch /
252
+ // browser_upload_file) and appear in CONSEQUENTIAL_KINDS below, but were
253
+ // absent HERE — and this map is the allow-list: performAct rejects any kind
254
+ // that is not a key with `act_kind_unknown`. So all three failed on the
255
+ // launch/attach transport while the extension transport implemented them,
256
+ // which made it look like a per-site quirk rather than a missing kind.
257
+ //
258
+ // Floors match the server's own classification (browserEffects.baseEffectOf)
259
+ // so the two transports gate identically:
260
+ submit: "publish", // committing entered data to the site
261
+ // A held drag moves something on the page (reorder, drop, slider): the
262
+ // same uncharacterised-write floor as tap/select_option.
263
+ drag_hold: "message",
264
+ upload_file: "upload",
265
+ // A batch's REAL class is the riskiest of its steps, and every step is
266
+ // re-gated individually by performAct as it runs. A "read" floor here gates
267
+ // the batch envelope only; it never lowers a step's own floor.
268
+ batch: "read",
227
269
  };
228
- // Consequential act kinds blocked while the user has taken over the browser.
229
- // Read-only kinds (get_text, get_screen_tree path, screenshot, wait,
230
- // wait_for_network_idle) are intentionally excluded so the agent can observe.
270
+ /** Deterministic PRNG. Jitter must be reproducible: a drag that fails should
271
+ * fail the same way twice, otherwise it cannot be debugged. */
272
+ function dragRandom(seed) {
273
+ let a = seed >>> 0;
274
+ return () => {
275
+ a = (a + 0x6d2b79f5) >>> 0;
276
+ let t = a;
277
+ t = Math.imul(t ^ (t >>> 15), t | 1);
278
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
279
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
280
+ };
281
+ }
282
+ /** Sample the travel into `steps` points, plus the ms to dwell after each.
283
+ * Returns points EXCLUDING the start (the pointer is already there) and
284
+ * ending exactly on the destination. */
285
+ function buildDragPath(from, to, steps, moveMs, shape) {
286
+ const curve = String(shape.curve || "linear").toLowerCase();
287
+ const dx = to.x - from.x;
288
+ const dy = to.y - from.y;
289
+ const dist = Math.hypot(dx, dy) || 1;
290
+ // Unit perpendicular to the straight line, for bow and jitter.
291
+ const nx = -dy / dist;
292
+ const ny = dx / dist;
293
+ // Curve presets are just defaults for the numeric knobs, so an explicit
294
+ // `arc` / `jitter` always wins over the preset.
295
+ const bowDefault = curve === "arc" ? 0.15 : curve === "human" ? 0.06 : 0;
296
+ const bow = (shape.arc ?? bowDefault) * dist;
297
+ const jitterAmp = (shape.jitter ?? (curve === "human" ? 0.012 : 0)) * dist;
298
+ const overshoot = curve === "overshoot" ? 0.1 * dist : 0;
299
+ const eased = curve === "ease" || curve === "human" || curve === "overshoot";
300
+ const rnd = dragRandom(Math.round(from.x * 7 + from.y * 13 + to.x * 17 + to.y * 23) || 1);
301
+ const ease = (t) => eased ? (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2) : t;
302
+ const wp = Array.isArray(shape.waypoints) ? shape.waypoints : [];
303
+ const nodes = [from, ...wp, to];
304
+ // Cumulative arc length so a polyline is sampled by DISTANCE, not by node:
305
+ // sampling per node would crawl through a short leg and rush a long one.
306
+ const segLen = [];
307
+ let total = 0;
308
+ for (let i = 1; i < nodes.length; i++) {
309
+ const a = nodes[i - 1];
310
+ const b = nodes[i];
311
+ const l = Math.hypot(b.x - a.x, b.y - a.y);
312
+ segLen.push(l);
313
+ total += l;
314
+ }
315
+ if (total <= 0)
316
+ total = 1;
317
+ const at = (u) => {
318
+ // u is 0..1 along the whole polyline.
319
+ if (nodes.length === 2) {
320
+ // Single segment: quadratic Bezier so `arc` actually bows it, plus an
321
+ // overshoot that carries past the target and returns.
322
+ const reach = 1 + (overshoot / dist) * Math.sin(Math.PI * u);
323
+ const cx = from.x + dx / 2 + nx * bow;
324
+ const cy = from.y + dy / 2 + ny * bow;
325
+ const mt = 1 - u;
326
+ const bxRaw = mt * mt * from.x + 2 * mt * u * cx + u * u * to.x;
327
+ const byRaw = mt * mt * from.y + 2 * mt * u * cy + u * u * to.y;
328
+ return { x: from.x + (bxRaw - from.x) * reach, y: from.y + (byRaw - from.y) * reach };
329
+ }
330
+ let d = u * total;
331
+ for (let i = 0; i < segLen.length; i++) {
332
+ const len = segLen[i];
333
+ if (d <= len || i === segLen.length - 1) {
334
+ const f = len > 0 ? Math.max(0, Math.min(1, d / len)) : 1;
335
+ const a = nodes[i];
336
+ const b = nodes[i + 1];
337
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
338
+ }
339
+ d -= len;
340
+ }
341
+ return to;
342
+ };
343
+ const n = Math.max(2, Math.min(240, steps));
344
+ const perStep = Math.max(4, Math.round(moveMs / n));
345
+ const pauses = (Array.isArray(shape.pauses) ? shape.pauses : [])
346
+ .filter((p) => Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]))
347
+ .map(([t, ms]) => [Math.max(0, Math.min(1, Number(t))), Math.max(0, Math.min(10_000, Number(ms)))]);
348
+ const out = [];
349
+ for (let i = 1; i <= n; i++) {
350
+ const raw = i / n;
351
+ const u = ease(raw);
352
+ const p = at(u);
353
+ // Jitter never applies to the final point: the release has to land exactly
354
+ // on the drop target, not near it.
355
+ const j = i < n && jitterAmp > 0 ? (rnd() - 0.5) * 2 * jitterAmp : 0;
356
+ // A pause "at t" fires on the step that first crosses t.
357
+ let dwell = perStep;
358
+ for (const [t, ms] of pauses) {
359
+ if ((i - 1) / n < t && raw >= t)
360
+ dwell += ms;
361
+ }
362
+ out.push({ x: p.x + nx * j, y: p.y + ny * j, dwellMs: dwell });
363
+ }
364
+ // Guarantee the exact destination regardless of easing rounding.
365
+ const last = out[out.length - 1];
366
+ out[out.length - 1] = { x: to.x, y: to.y, dwellMs: last.dwellMs };
367
+ return out;
368
+ }
369
+ // Mirrors shared/tools/browser.py's _BATCH_ALLOWED_KINDS / _BATCH_MAX_STEPS.
370
+ // The tool layer validates too; this is the enforcement point, because the
371
+ // bridge must not trust a caller that skipped it.
372
+ const BATCH_MAX_STEPS = 10;
373
+ const BATCH_ALLOWED_KINDS = new Set([
374
+ "navigate", "back", "forward", "click", "tap", "input_text", "press_key",
375
+ "scroll", "select_option", "submit", "wait", "wait_for_network_idle",
376
+ "get_text", "drag_hold",
377
+ ]);
231
378
  const CONSEQUENTIAL_KINDS = new Set([
232
379
  "navigate", "back", "forward",
233
- "click", "dblclick", "hover", "tap",
380
+ "click", "dblclick", "hover", "tap", "drag_hold",
234
381
  "input_text", "press_key", "scroll", "select_option",
235
382
  "upload_file", "submit", "ask_user", "batch",
236
383
  ]);
@@ -328,7 +475,7 @@ export async function startBrowserBridge(opts) {
328
475
  activePageByRunId.set(reg.spec.runId, page);
329
476
  // Enforce policy on the leased page only (same as cdp-attach mode):
330
477
  // we do not take over context-wide routing for the interactive session.
331
- await enforceOnPage(page, reg.policy, hooks);
478
+ reg.enforcement.push(await enforceOnPage(page, reg.policy, hooks));
332
479
  log(`browser session attached to interactive: run=${reg.spec.runId.slice(0, 10)} session=${browserSessionId.slice(0, 16)}`);
333
480
  // Return the interactive record directly — ensureSession callers
334
481
  // (getLease, captureSnapshot, etc.) work against it unchanged.
@@ -358,7 +505,7 @@ export async function startBrowserBridge(opts) {
358
505
  // only — we do not take over routing for the user's whole
359
506
  // externally owned context (plan Section 7 ownership rule;
360
507
  // context-wide routing is applied on owned contexts below).
361
- await enforceOnPage(page, reg.policy, hooks);
508
+ reg.enforcement.push(await enforceOnPage(page, reg.policy, hooks));
362
509
  void lease;
363
510
  return rec;
364
511
  }
@@ -379,7 +526,18 @@ export async function startBrowserBridge(opts) {
379
526
  const context = await playwright.chromium.launchPersistentContext(userDataDir, {
380
527
  executablePath: engine.executablePath,
381
528
  headless: reg.spec.headless === true,
382
- viewport: { width: 1280, height: 800 },
529
+ // HEADED: no viewport override. A fixed viewport applies a CDP
530
+ // device-metrics override, which decouples the rendered page from the
531
+ // OS window — the page paints into a 1280x800 box inside a window of a
532
+ // different size, and every re-sync (focus change, tab switch, a CDP
533
+ // session attaching or detaching, the 2-4 fps live-view capture) snaps
534
+ // it between the real window size and the override. That is the
535
+ // "screen flapping between full screen and a smaller container, on and
536
+ // off in a loop" users reported on their own launched browser.
537
+ //
538
+ // Headless has no window to disagree with, and a deterministic box is
539
+ // worth having there, so it keeps the fixed viewport.
540
+ viewport: reg.spec.headless === true ? { width: 1280, height: 800 } : null,
383
541
  acceptDownloads: false, // downloads default-denied (Section 10)
384
542
  ignoreDefaultArgs: ["--enable-automation"],
385
543
  args: [
@@ -399,7 +557,7 @@ export async function startBrowserBridge(opts) {
399
557
  await context.addInitScript(STEALTH_SCRIPT);
400
558
  }
401
559
  catch { /* non-fatal */ }
402
- await enforceOnContext(context, reg.policy, hooks);
560
+ reg.enforcement.push(await enforceOnContext(context, reg.policy, hooks));
403
561
  const page = context.pages()[0] ?? (await context.newPage());
404
562
  sessions.leaseTarget(rec, reg.spec.grant.target.ref, page);
405
563
  // Register this page as the initial active tab for this run.
@@ -503,7 +661,9 @@ export async function startBrowserBridge(opts) {
503
661
  context = await playwright.chromium.launchPersistentContext(userDataDir, {
504
662
  executablePath: resolved.executablePath,
505
663
  headless: false,
506
- viewport: { width: 1280, height: 800 },
664
+ // Always headed and always the USER'S OWN visible window: never
665
+ // override its metrics. See the note at the run-launch site.
666
+ viewport: null,
507
667
  acceptDownloads: false,
508
668
  // Suppress the "browser is being controlled by automated test
509
669
  // software" infobar that Chromium/Brave shows by default.
@@ -592,7 +752,7 @@ export async function startBrowserBridge(opts) {
592
752
  const frames = new Map();
593
753
  frames.set("main", page.mainFrame());
594
754
  frameMaps.set(lease, frames);
595
- const viewport = page.viewportSize() ?? { width: 1280, height: 800 };
755
+ const viewport = await liveViewport(page);
596
756
  const collected = [];
597
757
  // Main target: covers the top document + all SAME-PROCESS iframes
598
758
  // (the flattened DOMSnapshot + AX tree include them) + shadow DOM
@@ -861,7 +1021,7 @@ export async function startBrowserBridge(opts) {
861
1021
  }
862
1022
  async function resolveActionPoint(reg, rec, lease, args) {
863
1023
  const page = lease.page;
864
- const viewport = page.viewportSize() ?? { width: 1280, height: 800 };
1024
+ const viewport = await liveViewport(page);
865
1025
  if (args.ref) {
866
1026
  const binding = sessions.resolveRef(lease, args.ref);
867
1027
  const frames = frameMaps.get(lease);
@@ -1118,6 +1278,64 @@ export async function startBrowserBridge(opts) {
1118
1278
  "paused until they hand control back. Wait and retry, or call " +
1119
1279
  "browser_get_screen_tree / browser_screenshot to observe the page.");
1120
1280
  }
1281
+ // A declared, fixed step list run in ONE call (plan 0.8). Handled HERE,
1282
+ // before getLease/runOnTarget, and not as a switch case: runOnTarget
1283
+ // serialises through a promise chain per lease, so a nested performAct
1284
+ // would queue behind the very operation it is running inside and deadlock.
1285
+ //
1286
+ // Every step goes back through performAct, so each one is re-gated on its
1287
+ // own: effect ceiling, takeover pause, publish approval, origin policy.
1288
+ // The batch envelope's floor is "read"; a step never gets a cheaper gate
1289
+ // for being inside one.
1290
+ if (kind === "batch") {
1291
+ const rawSteps = Array.isArray(args.steps) ? args.steps : [];
1292
+ if (!rawSteps.length) {
1293
+ throw new BridgeError("act_args_invalid", "batch needs a non-empty steps list");
1294
+ }
1295
+ if (rawSteps.length > BATCH_MAX_STEPS) {
1296
+ throw new BridgeError("act_args_invalid", `batch takes at most ${BATCH_MAX_STEPS} steps; split the flow into several batches`);
1297
+ }
1298
+ const done = [];
1299
+ for (let i = 0; i < rawSteps.length; i++) {
1300
+ const raw = rawSteps[i];
1301
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1302
+ throw new BridgeError("act_args_invalid", `batch step ${i} is not an object`);
1303
+ }
1304
+ const step = raw;
1305
+ const stepKind = String(step.kind || "");
1306
+ if (!BATCH_ALLOWED_KINDS.has(stepKind)) {
1307
+ throw new BridgeError("act_args_invalid", `batch step ${i} kind '${stepKind}' is not allowed in a batch. Allowed: ` +
1308
+ `${[...BATCH_ALLOWED_KINDS].sort().join(", ")}. upload_file, run, ask_user and batch ` +
1309
+ "must be called on their own.");
1310
+ }
1311
+ try {
1312
+ // Inherit the batch's HITL mode so a step is gated exactly as it
1313
+ // would be standalone.
1314
+ const result = await performAct(reg, {
1315
+ ...step,
1316
+ ...(args.hitl_mode
1317
+ ? { hitl_mode: args.hitl_mode }
1318
+ : {}),
1319
+ });
1320
+ done.push({ step: i, kind: stepKind, ok: true, result });
1321
+ }
1322
+ catch (e) {
1323
+ // ABORT on the first failure and say where it stopped. Continuing
1324
+ // would run the rest of a flow whose precondition just failed — the
1325
+ // remaining steps were declared for a page that no longer exists.
1326
+ const err = e;
1327
+ throw new BridgeError(err?.code || "unknown_outcome", `batch stopped at step ${i} (${stepKind}): ${err?.message || String(e)}. ` +
1328
+ `${done.length} of ${rawSteps.length} steps completed; the page is left wherever that ` +
1329
+ "step landed. Re-read it before retrying, and do not re-run the whole batch blindly.");
1330
+ }
1331
+ const settle = Number(step.settle_ms ?? 0);
1332
+ if (settle > 0) {
1333
+ const { lease: l } = await getLease(reg);
1334
+ await l.page.waitForTimeout(Math.min(5_000, settle));
1335
+ }
1336
+ }
1337
+ return { batch: true, steps_run: done.length, steps: done };
1338
+ }
1121
1339
  const { rec, lease } = await getLease(reg);
1122
1340
  return sessions.runOnTarget(lease, async () => {
1123
1341
  if (reg.cancelled)
@@ -1129,7 +1347,45 @@ export async function startBrowserBridge(opts) {
1129
1347
  const d = await evaluateUrlResolved(url, reg.policy);
1130
1348
  if (!d.allowed)
1131
1349
  throw new BridgeError(d.code, d.message);
1132
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
1350
+ // Clearing the target URL is not the same as the navigation
1351
+ // succeeding. The route handler gates every REDIRECT HOP and every
1352
+ // subresource too, and a site whose apex redirects to www, or whose
1353
+ // document pulls its own CDN, fails on a hop this pre-check never
1354
+ // saw. Playwright surfaces that as a bare net::ERR_BLOCKED_BY_CLIENT
1355
+ // and Chrome paints it (blocked:devtools) — which reads as an
1356
+ // anti-bot block and sends everyone hunting in the wrong place.
1357
+ //
1358
+ // The reason was already recorded, in reg.violations, and then never
1359
+ // read by anything. Attach whatever this navigation produced so the
1360
+ // error names the origin to add instead of leaving a dead tab.
1361
+ const mark = reg.violations.length;
1362
+ try {
1363
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
1364
+ }
1365
+ catch (navErr) {
1366
+ const blocked = reg.violations.slice(mark);
1367
+ if (!blocked.length)
1368
+ throw navErr;
1369
+ const seen = new Map();
1370
+ for (const v of blocked) {
1371
+ let origin = v.url;
1372
+ try {
1373
+ origin = new URL(v.url).origin;
1374
+ }
1375
+ catch { /* keep raw */ }
1376
+ if (!seen.has(origin))
1377
+ seen.set(origin, v.code);
1378
+ }
1379
+ const detail = [...seen].slice(0, 8).map(([o, c]) => o + " (" + c + ")").join(", ");
1380
+ const first = blocked[0].code;
1381
+ throw new BridgeError(first === "run_cancelled" ? "run_cancelled" : "blocked_origin", "navigation to " + url + " was blocked by the origin policy, not by the site. " +
1382
+ "Blocked during this navigation: " + detail + ". " +
1383
+ (first === "run_cancelled"
1384
+ ? "The run that owns this page is already torn down, so its enforcement should " +
1385
+ "have been removed; this is a stale handler, not a policy decision."
1386
+ : "Add those origins to the grant, or grant all sites, then retry. A scope for an " +
1387
+ "apex domain does NOT cover its www host or its CDN hosts."));
1388
+ }
1133
1389
  return { navigated: page.url() };
1134
1390
  }
1135
1391
  case "back": {
@@ -1314,6 +1570,107 @@ export async function startBrowserBridge(opts) {
1314
1570
  await page.waitForTimeout(ms);
1315
1571
  return { waited: ms };
1316
1572
  }
1573
+ case "submit": {
1574
+ // Submit the form owning `ref`, or the one owning the focused field.
1575
+ // requestSubmit() is deliberate: unlike form.submit() it fires the
1576
+ // submit event and runs validation, so a site's own handler still
1577
+ // sees the submission. submit() is only the fallback for the few
1578
+ // forms that predate it.
1579
+ const ref = args.ref ? String(args.ref) : "";
1580
+ await enforcePublishGate(reg, page, kind, "", ref || "focused form", args.hitl_mode);
1581
+ const FIND_AND_SUBMIT = "function(){ var el = this; var f = el && el.closest ? el.closest('form') : null; " +
1582
+ "if (!f) return false; if (typeof f.requestSubmit === 'function') f.requestSubmit(); " +
1583
+ "else f.submit(); return true; }";
1584
+ const cdp = await getCdp(rec, page);
1585
+ let submitted = false;
1586
+ if (ref) {
1587
+ const binding = sessions.resolveRef(lease, ref);
1588
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId });
1589
+ const r = await cdp.send("Runtime.callFunctionOn", {
1590
+ objectId: resolved.object.objectId,
1591
+ functionDeclaration: FIND_AND_SUBMIT,
1592
+ returnByValue: true,
1593
+ });
1594
+ submitted = r.result?.value === true;
1595
+ }
1596
+ else {
1597
+ // Evaluated as a string through CDP rather than page.evaluate so
1598
+ // this file needs no DOM lib in its tsconfig.
1599
+ const r = await cdp.send("Runtime.evaluate", {
1600
+ returnByValue: true,
1601
+ expression: "(function(){ var el = document.activeElement; var f = el && el.closest ? el.closest('form') : null; " +
1602
+ "if (!f) return false; if (typeof f.requestSubmit === 'function') f.requestSubmit(); " +
1603
+ "else f.submit(); return true; })()",
1604
+ });
1605
+ submitted = r.result?.value === true;
1606
+ }
1607
+ if (!submitted) {
1608
+ throw new BridgeError("act_args_invalid", ref
1609
+ ? `no <form> ancestor for ref '${ref}'. Many sites submit with a button and no form element: ` +
1610
+ "click the submit control, or focus the field and press Enter with browser_press_key."
1611
+ : "nothing is focused, or the focused element is not inside a <form>. Click the field first, " +
1612
+ "or pass the field's @eN ref.");
1613
+ }
1614
+ return { submitted: true, via: ref || "focused" };
1615
+ }
1616
+ case "drag_hold": {
1617
+ // Press-and-HOLD, then move, then release — one continuous gesture.
1618
+ // A plain click-drag starts moving immediately, which sites read as a
1619
+ // scroll/selection instead of a grab; the hold is what makes a
1620
+ // drag-and-drop surface (a kanban card, a reorderable row, a slider
1621
+ // handle, a canvas object) actually pick the item up.
1622
+ const from = await resolveActionPoint(reg, rec, lease, {
1623
+ ...(args.ref ? { ref: args.ref } : {}),
1624
+ x: args.x1 ?? args.x, y: args.y1 ?? args.y,
1625
+ });
1626
+ const to = await resolveActionPoint(reg, rec, lease, {
1627
+ ...(args.ref2 ? { ref: args.ref2 } : {}),
1628
+ x: args.x2, y: args.y2,
1629
+ });
1630
+ const holdMs = Math.min(5_000, Math.max(0, Number(args.hold_ms ?? 600)));
1631
+ const moveMs = Math.min(10_000, Math.max(50, Number(args.move_ms ?? 500)));
1632
+ const settleMs = Math.min(5_000, Math.max(0, Number(args.settle_ms ?? 80)));
1633
+ const shape = {
1634
+ ...(args.curve ? { curve: String(args.curve) } : {}),
1635
+ ...(Number.isFinite(args.arc) ? { arc: Number(args.arc) } : {}),
1636
+ ...(Number.isFinite(args.jitter) ? { jitter: Number(args.jitter) } : {}),
1637
+ ...(Array.isArray(args.waypoints) ? { waypoints: args.waypoints } : {}),
1638
+ ...(Array.isArray(args.pauses) ? { pauses: args.pauses } : {}),
1639
+ };
1640
+ const name = await readAccessibleNameAtPoint(page, from.x, from.y);
1641
+ await enforcePublishGate(reg, page, kind, name, String(args.ref ?? `${Math.round(from.x)},${Math.round(from.y)}`), args.hitl_mode);
1642
+ await page.mouse.move(from.x, from.y);
1643
+ await page.mouse.down();
1644
+ // THE LONG PRESS. Button down, pointer still, nothing dispatched:
1645
+ // this interval is what the page uses to decide the item is picked
1646
+ // up, and it is the whole difference from a plain drag.
1647
+ await page.waitForTimeout(holdMs);
1648
+ const path = buildDragPath(from, to, Math.round(moveMs / 25), moveMs, shape);
1649
+ for (const p of path) {
1650
+ await page.mouse.move(p.x, p.y);
1651
+ await page.waitForTimeout(p.dwellMs);
1652
+ }
1653
+ // Rest on the target before releasing: dragover-driven drop zones
1654
+ // need a frame or two at rest, and a release mid-motion misses them.
1655
+ await page.waitForTimeout(settleMs);
1656
+ await page.mouse.up();
1657
+ return {
1658
+ from: { x: Math.round(from.x), y: Math.round(from.y) },
1659
+ to: { x: Math.round(to.x), y: Math.round(to.y) },
1660
+ hold_ms: holdMs, move_ms: moveMs, settle_ms: settleMs,
1661
+ curve: shape.curve ?? "linear", points: path.length,
1662
+ };
1663
+ }
1664
+ case "upload_file": {
1665
+ // Honest refusal, not act_kind_unknown. The tool is advertised and
1666
+ // the schema validates the handle shape, but NOTHING anywhere
1667
+ // resolves an approved handle back to bytes — not this bridge, not
1668
+ // the server, not the extension. Saying "unknown kind" sent the model
1669
+ // hunting for a different spelling of a tool that cannot work yet.
1670
+ throw new BridgeError("effect_not_granted", "File upload is not available on this transport: an approved file handle cannot be resolved to " +
1671
+ "a file yet, on any transport. Ask the USER to attach the file themselves with " +
1672
+ "browser_ask_user, then continue once they confirm.");
1673
+ }
1317
1674
  default:
1318
1675
  throw new BridgeError("act_kind_unknown", `unhandled kind '${kind}'`);
1319
1676
  }
@@ -1771,7 +2128,7 @@ export async function startBrowserBridge(opts) {
1771
2128
  return fail(503, "source_unavailable", "session context is not available");
1772
2129
  const newPage = await ctxOpen.newPage();
1773
2130
  // Enforce policy on the new page (same as enforceOnPage for attach mode).
1774
- await enforceOnPage(newPage, reg.policy, {
2131
+ reg.enforcement.push(await enforceOnPage(newPage, reg.policy, {
1775
2132
  onViolation: (v) => {
1776
2133
  reg.violations.push({ url: v.url.slice(0, 300), code: v.code, surface: v.surface, at: Date.now() });
1777
2134
  if (reg.violations.length > 200)
@@ -1779,7 +2136,7 @@ export async function startBrowserBridge(opts) {
1779
2136
  log(`[authz] DENY ${v.code} (${v.surface}) ${v.url.slice(0, 120)}`);
1780
2137
  },
1781
2138
  isCancelled: () => reg.cancelled,
1782
- });
2139
+ }));
1783
2140
  const newTabRef = getTabRef(newPage);
1784
2141
  // Navigate to the URL. The origin check above already cleared it.
1785
2142
  await newPage.goto(tabUrl, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
@@ -1952,6 +2309,7 @@ export async function startBrowserBridge(opts) {
1952
2309
  policy: buildOriginPolicy(spec.grant.originScopes),
1953
2310
  cancelled: false,
1954
2311
  violations: [],
2312
+ enforcement: [],
1955
2313
  sessionInit: null,
1956
2314
  traces: [],
1957
2315
  externalAttach: false,
@@ -1966,6 +2324,23 @@ export async function startBrowserBridge(opts) {
1966
2324
  if (!reg)
1967
2325
  return;
1968
2326
  reg.cancelled = true; // cancels in-flight ops at their gates
2327
+ // Remove this run's request enforcement from every page/context it
2328
+ // installed on, BEFORE anything else.
2329
+ //
2330
+ // On the owned-launch path the context is about to close and this is a
2331
+ // no-op. On the attach and interactive paths it is the whole point: those
2332
+ // deliberately leave the user's browser open, and the handler we installed
2333
+ // closes over THIS registration. With reg.cancelled now permanently true,
2334
+ // an undisposed handler aborts every request on that page for as long as
2335
+ // the user keeps it open, which Chrome shows as (blocked:devtools) and
2336
+ // which looks like an anti-bot block rather than our own dead handler.
2337
+ // Each new run stacked another one, so the tab only died once a run ended.
2338
+ //
2339
+ // Awaited, not fire-and-forget: a page that unroutes after the next run
2340
+ // has already installed its handler would tear down the LIVE one.
2341
+ for (const e of reg.enforcement.splice(0)) {
2342
+ await e.dispose().catch(() => { });
2343
+ }
1969
2344
  byRunId.delete(runId);
1970
2345
  byToken.delete(reg.token);
1971
2346
  activePageByRunId.delete(runId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.23",
3
+ "version": "1.1.24",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,