@melaya/runner 1.1.12 → 1.1.14

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.
@@ -621,7 +621,11 @@ def _build_agent():
621
621
  toolkit=toolkit,
622
622
  model_name=model,
623
623
  provider=provider,
624
- api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
624
+ # Cloud providers on the runner path (browser control turns pinned to the
625
+ # runner even for a cloud model) get their key passed down from the server
626
+ # as MEL_ASSISTANT_PROVIDER_API_KEY. Empty for local providers (claude_code
627
+ # OAuth / ollama / lmstudio), where model.py resolves on-disk/localhost auth.
628
+ api_key=os.environ.get("MEL_ASSISTANT_PROVIDER_API_KEY", "") or "",
625
629
  # Phone tasks that act on N items ("like + comment on 10 posts") take
626
630
  # ~10-15 tool calls EACH (screen_tree→read→tap→type→post_comment→verify→
627
631
  # swipe to the next), so a flat 40 capped a 10-post run at ~3 done and
@@ -59,7 +59,7 @@ const MELAYA_BRAND_SCRIPT = `(() => {
59
59
  if (document.getElementById(ID + "_border")) return;
60
60
  const style = document.createElement("style");
61
61
  style.id = ID + "_style";
62
- style.textContent = "@keyframes melBrandPulse{0%,100%{box-shadow:inset 0 0 9px 2px rgba(34,211,238,.5),inset 0 0 30px 7px rgba(124,111,240,.18)}50%{box-shadow:inset 0 0 16px 4px rgba(34,211,238,.8),inset 0 0 54px 13px rgba(124,111,240,.32)}}@keyframes melBrandGlow{0%,100%{opacity:.78}50%{opacity:1}}#" + ID + "_border{position:fixed;inset:0;pointer-events:none;z-index:2147483647;border-radius:7px;animation:melBrandPulse 2.6s ease-in-out infinite}#" + ID + "_badge{position:fixed;top:10px;left:50%;transform:translateX(-50%);pointer-events:none;z-index:2147483647;font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;font-size:12px;line-height:1;white-space:nowrap;max-width:calc(100vw - 24px);box-sizing:border-box;color:#E2E8F0;background:linear-gradient(180deg,rgba(15,23,42,.94),rgba(15,23,42,.82));padding:5px 13px 5px 7px;border-radius:999px;letter-spacing:.2px;border:1px solid rgba(34,211,238,.45);box-shadow:0 0 16px 1px rgba(34,211,238,.4),0 4px 14px rgba(0,0,0,.45);display:flex;align-items:center;gap:8px;backdrop-filter:blur(6px);animation:melBrandGlow 2.6s ease-in-out infinite}#" + ID + "_logo{height:16px;width:auto;display:block;filter:drop-shadow(0 0 4px rgba(34,211,238,.55))}@media (max-width:600px){#" + ID + "_badge{font-size:10px;padding:4px 10px 4px 6px;gap:6px;top:6px}#" + ID + "_logo{height:13px}}";
62
+ style.textContent = "@keyframes melBrandPulse{0%,100%{box-shadow:inset 0 0 9px 2px rgba(34,211,238,.5),inset 0 0 30px 7px rgba(124,111,240,.18)}50%{box-shadow:inset 0 0 16px 4px rgba(34,211,238,.8),inset 0 0 54px 13px rgba(124,111,240,.32)}}@keyframes melBrandGlow{0%,100%{opacity:.78}50%{opacity:1}}#" + ID + "_border{position:fixed;inset:0;pointer-events:none;z-index:2147483647;border-radius:7px;animation:melBrandPulse 2.6s ease-in-out infinite}#" + ID + "_badge{position:fixed;top:10px;left:50%;transform:translateX(-50%);pointer-events:none;z-index:2147483647;font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;font-size:12px;line-height:16px;white-space:nowrap;max-width:calc(100vw - 24px);box-sizing:border-box;color:#E2E8F0;background:linear-gradient(180deg,rgba(15,23,42,.94),rgba(15,23,42,.82));padding:5px 13px 5px 7px;border-radius:999px;letter-spacing:.2px;border:1px solid rgba(34,211,238,.45);box-shadow:0 0 16px 1px rgba(34,211,238,.4),0 4px 14px rgba(0,0,0,.45);display:flex;align-items:center;gap:8px;backdrop-filter:blur(6px);animation:melBrandGlow 2.6s ease-in-out infinite}#" + ID + "_logo{height:16px;width:auto;display:block;filter:drop-shadow(0 0 4px rgba(34,211,238,.55))}@media (max-width:600px){#" + ID + "_badge{font-size:10px;line-height:13px;padding:4px 10px 4px 6px;gap:6px;top:6px}#" + ID + "_logo{height:13px}}";
63
63
  (document.head || root).appendChild(style);
64
64
  const border = document.createElement("div"); border.id = ID + "_border";
65
65
  const badge = document.createElement("div"); badge.id = ID + "_badge";
@@ -70,6 +70,120 @@ const MELAYA_BRAND_SCRIPT = `(() => {
70
70
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", inject); else inject();
71
71
  try { new MutationObserver(() => { if (!document.getElementById(ID + "_border")) inject(); }).observe(document.documentElement, { childList: true }); } catch (e) {}
72
72
  })();`;
73
+ // ── Stealth init script ────────────────────────────────────────────────────
74
+ // Runs in EVERY frame BEFORE page scripts (context.addInitScript). A headed
75
+ // Brave/Chrome already carries a genuine fingerprint, so this only patches the
76
+ // handful of vectors a CDP-driven session can still leak (webdriver flag, an
77
+ // inconsistent Notification permission, a missing chrome stub, empty plugins,
78
+ // masked WebGL vendor). It cannot hide the CDP transport itself — that is why
79
+ // behaviour is also humanised below — but it defeats the common JS bot checks.
80
+ const STEALTH_SCRIPT = `(() => {
81
+ const def = (o, k, get) => { try { Object.defineProperty(o, k, { get, configurable: true }); } catch (e) {} };
82
+ // navigator.webdriver → false (belt-and-suspenders; the launch flag already
83
+ // disables it, but some builds still expose it truthy under CDP).
84
+ def(Object.getPrototypeOf(navigator) || navigator, "webdriver", () => false);
85
+ try { delete Navigator.prototype.webdriver; } catch (e) {}
86
+ // Minimal chrome runtime stub when absent (headless-style gap).
87
+ try { window.chrome = window.chrome || {}; window.chrome.runtime = window.chrome.runtime || {}; } catch (e) {}
88
+ def(navigator, "languages", () => ["en-US", "en"]);
89
+ // Non-empty plugins/mimeTypes so length checks pass.
90
+ try { if (!navigator.plugins || navigator.plugins.length === 0) def(navigator, "plugins", () => [1, 2, 3, 4, 5]); } catch (e) {}
91
+ // Notification permission must agree between Notification.permission and
92
+ // permissions.query — a classic mismatch CDP introduces.
93
+ try {
94
+ const q = navigator.permissions && navigator.permissions.query ? navigator.permissions.query.bind(navigator.permissions) : null;
95
+ if (q) navigator.permissions.query = (p) => (p && p.name === "notifications")
96
+ ? Promise.resolve({ state: (typeof Notification !== "undefined" ? Notification.permission : "default"), onchange: null })
97
+ : q(p);
98
+ } catch (e) {}
99
+ // Mask WebGL UNMASKED_VENDOR/RENDERER (37445/37446) to a plausible GPU.
100
+ try {
101
+ const patch = (proto) => { if (!proto) return; const gp = proto.getParameter; if (!gp) return; proto.getParameter = function (p) { if (p === 37445) return "Intel Inc."; if (p === 37446) return "Intel Iris OpenGL Engine"; return gp.call(this, p); }; };
102
+ patch(window.WebGLRenderingContext && WebGLRenderingContext.prototype);
103
+ patch(window.WebGL2RenderingContext && WebGL2RenderingContext.prototype);
104
+ } catch (e) {}
105
+ def(navigator, "hardwareConcurrency", () => 8);
106
+ def(navigator, "deviceMemory", () => 8);
107
+ })();`;
108
+ // ── Humanized input helpers ────────────────────────────────────────────────
109
+ // Instant teleport-clicks and zero-delay typing are a behavioural bot tell.
110
+ // These move the pointer along an eased, jittered Bezier path, dwell before
111
+ // pressing, type with variable per-key cadence, and scroll in small steps.
112
+ // (PWPage is already aliased above to import("playwright").Page.)
113
+ const _rand = (min, max) => min + Math.random() * (max - min);
114
+ const _randInt = (min, max) => Math.floor(_rand(min, max + 1));
115
+ const _lastMouse = new WeakMap();
116
+ async function humanPause(page, min = 40, max = 140) {
117
+ await page.waitForTimeout(_randInt(min, max));
118
+ }
119
+ // Eased, jittered pointer travel from the last known position to (tx,ty).
120
+ async function humanMove(page, tx, ty) {
121
+ const vp = page.viewportSize() ?? { width: 1280, height: 800 };
122
+ const from = _lastMouse.get(page) ?? { x: _rand(0, vp.width), y: _rand(0, vp.height) };
123
+ const dist = Math.hypot(tx - from.x, ty - from.y);
124
+ const steps = Math.max(6, Math.min(42, Math.round(dist / _rand(18, 34))));
125
+ const curve = Math.min(90, dist * 0.28);
126
+ const midX = (from.x + tx) / 2 + _rand(-1, 1) * curve;
127
+ const midY = (from.y + ty) / 2 + _rand(-1, 1) * curve;
128
+ for (let i = 1; i <= steps; i++) {
129
+ const t = i / steps;
130
+ const e = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // easeInOutQuad
131
+ const mt = 1 - e;
132
+ let x = mt * mt * from.x + 2 * mt * e * midX + e * e * tx;
133
+ let y = mt * mt * from.y + 2 * mt * e * midY + e * e * ty;
134
+ if (i < steps) {
135
+ x += _rand(-1.2, 1.2);
136
+ y += _rand(-1.2, 1.2);
137
+ } // hand tremor
138
+ await page.mouse.move(x, y);
139
+ if (i % _randInt(3, 5) === 0)
140
+ await page.waitForTimeout(_randInt(4, 16));
141
+ }
142
+ _lastMouse.set(page, { x: tx, y: ty });
143
+ }
144
+ // Move to a point a few px off the exact center, dwell, then press/release.
145
+ async function humanClick(page, x, y, clickCount = 1) {
146
+ const jx = x + _rand(-3, 3), jy = y + _rand(-3, 3);
147
+ await humanMove(page, jx, jy);
148
+ await humanPause(page, 30, 120);
149
+ for (let c = 0; c < clickCount; c++) {
150
+ await page.mouse.down();
151
+ await page.waitForTimeout(_randInt(40, 110)); // press dwell
152
+ await page.mouse.up();
153
+ if (c + 1 < clickCount)
154
+ await page.waitForTimeout(_randInt(60, 130));
155
+ }
156
+ _lastMouse.set(page, { x: jx, y: jy });
157
+ }
158
+ // Type with variable cadence: slower on spaces, longer at punctuation, and an
159
+ // occasional "thinking" pause.
160
+ async function humanType(page, text) {
161
+ for (const ch of text) {
162
+ await page.keyboard.type(ch);
163
+ let d = _rand(45, 150);
164
+ if (ch === " ")
165
+ d += _rand(10, 60);
166
+ if (/[.,!?;:]/.test(ch))
167
+ d += _rand(80, 220);
168
+ if (Math.random() < 0.04)
169
+ d += _rand(180, 450);
170
+ await page.waitForTimeout(Math.round(d));
171
+ }
172
+ }
173
+ // Break a wheel scroll into a few smaller, jittered steps with pauses.
174
+ async function humanScroll(page, dx, dy) {
175
+ const steps = _randInt(3, 6);
176
+ let sx = 0, sy = 0;
177
+ for (let i = 0; i < steps; i++) {
178
+ const px = Math.round(dx / steps + _rand(-8, 8));
179
+ const py = Math.round(dy / steps + _rand(-14, 14));
180
+ await page.mouse.wheel(px, py);
181
+ sx += px;
182
+ sy += py;
183
+ await page.waitForTimeout(_randInt(40, 130));
184
+ }
185
+ return { dx: sx, dy: sy };
186
+ }
73
187
  const MAX_BODY_BYTES = 512 * 1024; // /browser/run scripts included
74
188
  const NODE_CAP = 240; // snapshot node cap (Section 8)
75
189
  const SCREENSHOT_MAX_B64 = 1_400_000; // ~1 MB binary; ladder recompresses
@@ -260,17 +374,24 @@ export async function startBrowserBridge(opts) {
260
374
  headless: reg.spec.headless === true,
261
375
  viewport: { width: 1280, height: 800 },
262
376
  acceptDownloads: false, // downloads default-denied (Section 10)
377
+ ignoreDefaultArgs: ["--enable-automation"],
263
378
  args: [
264
379
  "--no-first-run",
265
380
  "--no-default-browser-check",
266
381
  "--disable-background-networking",
267
382
  "--disable-sync",
383
+ "--disable-blink-features=AutomationControlled",
268
384
  ],
269
385
  });
270
386
  sessions.attachHandles(rec, {
271
387
  context,
272
388
  ephemeralUserDataDir: ephemeral ? userDataDir : null,
273
389
  });
390
+ // Stealth patches before any page script (see STEALTH_SCRIPT).
391
+ try {
392
+ await context.addInitScript(STEALTH_SCRIPT);
393
+ }
394
+ catch { /* non-fatal */ }
274
395
  await enforceOnContext(context, reg.policy, hooks);
275
396
  const page = context.pages()[0] ?? (await context.newPage());
276
397
  sessions.leaseTarget(rec, reg.spec.grant.target.ref, page);
@@ -401,6 +522,14 @@ export async function startBrowserBridge(opts) {
401
522
  // all navigations and new pages. The one-shot evaluate covers the
402
523
  // already-open page (typically about:blank) immediately.
403
524
  // Both are wrapped so a branding failure cannot fail the launch.
525
+ // Stealth FIRST (runs before page + brand scripts in every frame) so the
526
+ // navigator/webgl patches are in place before any site fingerprinting.
527
+ try {
528
+ await context.addInitScript(STEALTH_SCRIPT);
529
+ }
530
+ catch (stealthErr) {
531
+ log(`[browser-launch] stealth init-script install failed (non-fatal): ${stealthErr?.message || stealthErr}`);
532
+ }
404
533
  try {
405
534
  await context.addInitScript(MELAYA_BRAND_SCRIPT);
406
535
  }
@@ -852,16 +981,16 @@ export async function startBrowserBridge(opts) {
852
981
  }
853
982
  const pt = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
854
983
  if (kind === "hover")
855
- await page.mouse.move(pt.x, pt.y);
984
+ await humanMove(page, pt.x, pt.y);
856
985
  else
857
- await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
986
+ await humanClick(page, pt.x, pt.y, kind === "dblclick" ? 2 : 1);
858
987
  return { at: { x: Math.round(pt.x), y: Math.round(pt.y) }, matched_text: label };
859
988
  }
860
989
  const pt = await resolveActionPoint(reg, rec, lease, args);
861
990
  if (kind === "hover")
862
- await page.mouse.move(pt.x, pt.y);
991
+ await humanMove(page, pt.x, pt.y);
863
992
  else
864
- await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
993
+ await humanClick(page, pt.x, pt.y, kind === "dblclick" ? 2 : 1);
865
994
  return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
866
995
  }
867
996
  case "tap": {
@@ -869,7 +998,7 @@ export async function startBrowserBridge(opts) {
869
998
  // browser.py sends {x, y} as 0..1 fractions or CSS px.
870
999
  // resolveActionPoint already handles the fraction/px heuristic.
871
1000
  const pt = await resolveActionPoint(reg, rec, lease, args);
872
- await page.mouse.click(pt.x, pt.y);
1001
+ await humanClick(page, pt.x, pt.y);
873
1002
  return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
874
1003
  }
875
1004
  case "input_text": {
@@ -877,10 +1006,11 @@ export async function startBrowserBridge(opts) {
877
1006
  if (text.length > 20_000)
878
1007
  throw new BridgeError("act_args_invalid", "text exceeds 20000 chars");
879
1008
  const pt = await resolveActionPoint(reg, rec, lease, args);
880
- await page.mouse.click(pt.x, pt.y);
881
- // Clear-then-type: select-all + type preserves site key handlers.
1009
+ await humanClick(page, pt.x, pt.y);
1010
+ // Clear-then-type: select-all + humanized type preserves site key handlers.
882
1011
  await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => { });
883
- await page.keyboard.type(text, { delay: 8 });
1012
+ await humanPause(page, 40, 120);
1013
+ await humanType(page, text);
884
1014
  return { typed: text.length };
885
1015
  }
886
1016
  case "press_key": {
@@ -920,8 +1050,8 @@ export async function startBrowserBridge(opts) {
920
1050
  else {
921
1051
  dy = Math.max(-4000, Math.min(4000, Number(args.dy ?? 600)));
922
1052
  }
923
- await page.mouse.wheel(dx, dy);
924
- return { scrolled: { dx, dy } };
1053
+ const scrolled = await humanScroll(page, dx, dy);
1054
+ return { scrolled };
925
1055
  }
926
1056
  case "get_text": {
927
1057
  // Fix 1: read-only page-text extraction.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,