@melaya/runner 1.1.16 → 1.1.18

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.
@@ -272,17 +272,25 @@ def _apply_browser_turn_grant(req) -> bool:
272
272
  present (injected by the runner's per-turn bridge registration). These are
273
273
  purged at turn end alongside the grant (see _purge_browser_turn_grant)."""
274
274
  grant = str((req or {}).get("browser_grant") or "") if isinstance(req, dict) else ""
275
+ # Front 3: EXTENSION-target proxy turn — no client-side grant; the host's
276
+ # browser tools call the server bridge (MEL_BROWSER_URL/TOKEN) which holds the
277
+ # grant and runs the action against the user's real tab.
278
+ ext_proxy = bool((req or {}).get("browser_ext_proxy")) if isinstance(req, dict) else False
275
279
  target_ref = str((req or {}).get("browser_target_ref") or "") if isinstance(req, dict) else ""
276
- if grant and _browser_capable():
277
- os.environ["MEL_BROWSER_TURN_GRANT"] = grant
280
+ bridge_url = str((req or {}).get("mel_browser_url") or "") if isinstance(req, dict) else ""
281
+ bridge_token = str((req or {}).get("mel_browser_token") or "") if isinstance(req, dict) else ""
282
+ if (grant or ext_proxy) and _browser_capable():
283
+ if grant:
284
+ os.environ["MEL_BROWSER_TURN_GRANT"] = grant
285
+ else:
286
+ os.environ.pop("MEL_BROWSER_TURN_GRANT", None) # proxy: grant stays server-side
278
287
  if target_ref:
279
288
  os.environ["MEL_BROWSER_TURN_TARGET_REF"] = target_ref
280
289
  else:
281
290
  os.environ.pop("MEL_BROWSER_TURN_TARGET_REF", None)
282
- # Per-turn bridge URL + token (assistant path only; pipeline path has
283
- # these in the spawn env already). Apply only when present on the frame.
284
- bridge_url = str((req or {}).get("mel_browser_url") or "") if isinstance(req, dict) else ""
285
- bridge_token = str((req or {}).get("mel_browser_token") or "") if isinstance(req, dict) else ""
291
+ # Per-turn bridge URL + token (assistant path). For the runner target this
292
+ # is the runner's local bridge; for the extension proxy it is the server's
293
+ # /ext-bridge. Apply only when present on the frame.
286
294
  if bridge_url:
287
295
  os.environ["MEL_BROWSER_URL"] = bridge_url
288
296
  if bridge_token:
@@ -704,7 +712,18 @@ def _register_stream_hooks(agent) -> None:
704
712
  return
705
713
  prev = _stream["lens"].get(mid, 0)
706
714
  if len(text) > prev:
707
- _emit(tid, "delta", content=text[prev:])
715
+ # ReAct emits one message PER iteration: the running chain-of-thought
716
+ # ("Let me...", "Actually...", the plan before each tool call). That
717
+ # is REASONING, not the answer. Streaming it as `delta` dumped every
718
+ # round's raw thought into the chat bubble with no separator between
719
+ # rounds ("...page content.I notice..."), which read as broken run-on
720
+ # sentences. Route it to `think` instead (the ephemeral thinking
721
+ # strip) so it MIRRORS the cloud provider mapping (thinking_delta ->
722
+ # think, answer -> text). The clean authoritative answer still lands
723
+ # in the bubble via the end-of-turn `text` event (_run_turn), so the
724
+ # message reads like one professional reply, reasoning stays in its
725
+ # own channel, and tool chips show the live steps.
726
+ _emit(tid, "think", content=text[prev:])
708
727
  _stream["lens"][mid] = len(text)
709
728
  except Exception:
710
729
  pass
@@ -10,6 +10,8 @@ export interface BrowserRunSpec {
10
10
  space?: SpaceSpec;
11
11
  codeMode?: boolean;
12
12
  headless?: boolean;
13
+ serverBase?: string;
14
+ runAuthHeader?: string;
13
15
  }
14
16
  export interface BrowserBridge {
15
17
  url: string;
@@ -38,6 +38,7 @@ import { buildOriginPolicy, evaluateUrlResolved, checkEffect, enforceOnContext,
38
38
  import { SessionManager, SessionError, spaceUserDataDir, } from "./sessionManager.js";
39
39
  import { ensureEngine } from "./browserProvisioner.js";
40
40
  import { runSandboxedScript } from "./codeWorker.js";
41
+ import { classifyControlEffect } from "./publishGate.js";
41
42
  // ---------------------------------------------------------------------
42
43
  // Limits and constants
43
44
  // ---------------------------------------------------------------------
@@ -914,6 +915,165 @@ export async function startBrowserBridge(opts) {
914
915
  return true;
915
916
  return false;
916
917
  }
918
+ // ── Route 1 publish gate (phone detectPublish mirror) ───────────────
919
+ // The bridge is the only place that reads the clicked control's TRUSTED
920
+ // accessible name. For click/dblclick/tap we read that name, classify it
921
+ // into a consequential effect ("publish"|"purchase"), and, when the grant
922
+ // does not permit the effect unattended, stage a server approval and refuse
923
+ // to execute until it is approved (browser.py's poll loop re-issues the same
924
+ // act, which re-detects and re-calls the idempotent endpoint).
925
+ /** Read the accessible name of the element at (x,y) via page.evaluate. Walks
926
+ * from the element at the point up to its closest clickable ancestor
927
+ * (button, a, [role=button], [type=submit]) and reads, in precedence order:
928
+ * aria-label, else innerText/textContent, else value, else title. Returns a
929
+ * lowercased + trimmed string (empty when nothing is readable). Never
930
+ * throws: on any page error it returns "" so the caller treats it as
931
+ * "no match" and runs the action normally. */
932
+ async function readAccessibleNameAtPoint(page, x, y) {
933
+ try {
934
+ const raw = await page.evaluate(([px, py]) => {
935
+ const clickableSel = "button, a, [role=button], [type=submit]";
936
+ let el = document.elementFromPoint(px, py);
937
+ if (!el)
938
+ return "";
939
+ // Prefer the closest clickable ancestor (or self) so a click on an
940
+ // inner <span> still resolves the button's own label.
941
+ const clickable = el.closest(clickableSel) || el;
942
+ el = clickable;
943
+ const aria = el.getAttribute && el.getAttribute("aria-label");
944
+ if (aria && aria.trim())
945
+ return aria;
946
+ const txt = (el.innerText || el.textContent || "").trim();
947
+ if (txt)
948
+ return txt;
949
+ const val = el.value;
950
+ if (val && String(val).trim())
951
+ return String(val);
952
+ const title = el.getAttribute && el.getAttribute("title");
953
+ if (title && title.trim())
954
+ return title;
955
+ return "";
956
+ }, [x, y]);
957
+ return String(raw || "").toLowerCase().trim();
958
+ }
959
+ catch {
960
+ return "";
961
+ }
962
+ }
963
+ /** Stage a human approval on the server for a consequential click/tap and
964
+ * return the decision. Reuses the SAME server base URL + Bearer run token
965
+ * frame posting uses (spec.serverBase + spec.runAuthHeader, derived in
966
+ * connection.ts from opts.serverUrl + opts.token). Fails closed: any
967
+ * missing wiring, network error, non-2xx status, or unparseable body maps
968
+ * to "fail_closed" so the caller refuses to run the action. */
969
+ async function stagePublishApproval(reg, args) {
970
+ const serverBase = String(reg.spec.serverBase || "").replace(/\/$/, "");
971
+ const authHeader = String(reg.spec.runAuthHeader || "");
972
+ if (!serverBase || !authHeader)
973
+ return "fail_closed";
974
+ const grant = reg.spec.grant;
975
+ const body = JSON.stringify({
976
+ session_id: grant.browserSession,
977
+ run_id: reg.spec.runId,
978
+ target_ref: grant.target.ref,
979
+ top_origin: args.origin,
980
+ effect: args.effect,
981
+ kind: args.kind,
982
+ ref: args.ref,
983
+ });
984
+ return await new Promise((resolve) => {
985
+ try {
986
+ const u = new URL(serverBase + "/api/v1/browser/runner/approval");
987
+ const isHttps = u.protocol === "https:";
988
+ const req = (isHttps ? httpsRequest : httpRequest)({
989
+ hostname: u.hostname,
990
+ port: u.port || (isHttps ? 443 : 80),
991
+ path: u.pathname + u.search,
992
+ method: "POST",
993
+ headers: {
994
+ "Content-Type": "application/json",
995
+ "Content-Length": Buffer.byteLength(body),
996
+ "Authorization": authHeader,
997
+ },
998
+ }, (res) => {
999
+ const status = res.statusCode || 0;
1000
+ const chunks = [];
1001
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
1002
+ res.on("end", () => {
1003
+ if (status < 200 || status >= 300) {
1004
+ resolve("fail_closed");
1005
+ return;
1006
+ }
1007
+ try {
1008
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1009
+ const decision = String(parsed?.decision || "");
1010
+ if (decision === "approved" ||
1011
+ decision === "pending" ||
1012
+ decision === "rejected" ||
1013
+ decision === "canceled") {
1014
+ resolve(decision);
1015
+ }
1016
+ else {
1017
+ resolve("fail_closed");
1018
+ }
1019
+ }
1020
+ catch {
1021
+ resolve("fail_closed");
1022
+ }
1023
+ });
1024
+ });
1025
+ req.on("error", () => resolve("fail_closed"));
1026
+ req.end(body);
1027
+ }
1028
+ catch {
1029
+ resolve("fail_closed");
1030
+ }
1031
+ });
1032
+ }
1033
+ /** Escalate a resolved click/tap from its trusted accessible name and, when
1034
+ * the effect is consequential and not permitted unattended, stage an
1035
+ * approval and throw the contract BridgeError until it is approved. Returns
1036
+ * normally (falls through to execution) when there is nothing to gate or
1037
+ * the approval came back "approved". Applies to click/dblclick/tap only;
1038
+ * callers must not invoke it for hover. */
1039
+ async function enforcePublishGate(reg, page, kind, name, ref) {
1040
+ const escalated = classifyControlEffect(name);
1041
+ if (!escalated)
1042
+ return; // not a publish/purchase control: run normally.
1043
+ // If the grant permits this effect unattended, do not gate.
1044
+ const permitted = checkEffect(escalated, reg.spec.grant);
1045
+ if (permitted.allowed)
1046
+ return;
1047
+ // SAFE mode: not permitted unattended. Stage a server approval.
1048
+ let origin = "";
1049
+ try {
1050
+ origin = new URL(page.url()).origin;
1051
+ }
1052
+ catch {
1053
+ origin = page.url();
1054
+ }
1055
+ const approvalKind = kind === "tap" ? "tap" : "click";
1056
+ const decision = await stagePublishApproval(reg, {
1057
+ effect: escalated,
1058
+ kind: approvalKind,
1059
+ ref,
1060
+ origin,
1061
+ });
1062
+ if (decision === "approved")
1063
+ return; // fall through to execution.
1064
+ if (decision === "pending") {
1065
+ throw new BridgeError("approval_required", "Waiting for your approval to " + escalated + " on " + origin +
1066
+ ". A card is showing in Melaya; approve it and this will proceed.");
1067
+ }
1068
+ if (decision === "rejected") {
1069
+ throw new BridgeError("approval_required", "You declined this action, so it was not run. Ask the user what to do instead.");
1070
+ }
1071
+ if (decision === "canceled") {
1072
+ throw new BridgeError("canceled", "The approval was canceled.");
1073
+ }
1074
+ // fail_closed (missing wiring, network/HTTP error, bad body).
1075
+ throw new BridgeError("approval_required", "Could not reach the approval service; not running this consequential action until it is approved.");
1076
+ }
917
1077
  async function performAct(reg, args) {
918
1078
  const kind = String(args.kind || "");
919
1079
  if (!(kind in KIND_MIN_EFFECT)) {
@@ -989,6 +1149,12 @@ export async function startBrowserBridge(opts) {
989
1149
  "with a ref instead of a text label.");
990
1150
  }
991
1151
  const pt = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
1152
+ // Publish gate: when text located the element, that text IS the
1153
+ // control's accessible name (no extra page read needed). hover is
1154
+ // never gated.
1155
+ if (kind !== "hover") {
1156
+ await enforcePublishGate(reg, page, kind, label, label);
1157
+ }
992
1158
  if (kind === "hover")
993
1159
  await humanMove(page, pt.x, pt.y);
994
1160
  else
@@ -996,6 +1162,12 @@ export async function startBrowserBridge(opts) {
996
1162
  return { at: { x: Math.round(pt.x), y: Math.round(pt.y) }, matched_text: label };
997
1163
  }
998
1164
  const pt = await resolveActionPoint(reg, rec, lease, args);
1165
+ // Publish gate for the ref/coords branch: read the trusted accessible
1166
+ // name at the resolved point and escalate. hover is never gated.
1167
+ if (kind !== "hover") {
1168
+ const name = await readAccessibleNameAtPoint(page, pt.x, pt.y);
1169
+ await enforcePublishGate(reg, page, kind, name, String(args.ref ?? `${Math.round(pt.x)},${Math.round(pt.y)}`));
1170
+ }
999
1171
  if (kind === "hover")
1000
1172
  await humanMove(page, pt.x, pt.y);
1001
1173
  else
@@ -1007,6 +1179,9 @@ export async function startBrowserBridge(opts) {
1007
1179
  // browser.py sends {x, y} as 0..1 fractions or CSS px.
1008
1180
  // resolveActionPoint already handles the fraction/px heuristic.
1009
1181
  const pt = await resolveActionPoint(reg, rec, lease, args);
1182
+ // Publish gate: read the trusted accessible name at the tapped point.
1183
+ const name = await readAccessibleNameAtPoint(page, pt.x, pt.y);
1184
+ await enforcePublishGate(reg, page, kind, name, String(args.ref ?? `${Math.round(pt.x)},${Math.round(pt.y)}`));
1010
1185
  await humanClick(page, pt.x, pt.y);
1011
1186
  return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
1012
1187
  }
@@ -454,6 +454,10 @@ export async function connect(opts) {
454
454
  space: payload.browser?.space ?? { kind: "ephemeral" },
455
455
  codeMode: payload.browser?.codeMode === true,
456
456
  headless: payload.browser?.headless === true,
457
+ // Route 1 publish gate: same server base + Bearer run token the
458
+ // frame POST path uses (ws/wss -> http/https).
459
+ serverBase: opts.serverUrl.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://")),
460
+ runAuthHeader: `Bearer ${opts.token}`,
457
461
  };
458
462
  const { token } = bridge.registerRun(spec);
459
463
  browserToken = token;
@@ -1541,6 +1545,10 @@ export async function connect(opts) {
1541
1545
  space: { kind: "ephemeral" },
1542
1546
  codeMode: false,
1543
1547
  headless: false,
1548
+ // Route 1 publish gate: same server base + Bearer run token the
1549
+ // frame POST path uses (ws/wss -> http/https).
1550
+ serverBase: opts.serverUrl.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://")),
1551
+ runAuthHeader: `Bearer ${opts.token}`,
1544
1552
  };
1545
1553
  const { token } = bridge.registerRun(spec);
1546
1554
  turnBridgeRunId = turnRunId;
@@ -1569,6 +1577,16 @@ export async function connect(opts) {
1569
1577
  return;
1570
1578
  }
1571
1579
  }
1580
+ else if (payload.browserExtProxy && payload.browserExtProxy.url && payload.browserExtProxy.token) {
1581
+ // Front 3: EXTENSION target. No grant verification / local bridge run — the
1582
+ // host's browser_* tools POST to the server bridge (Bearer proxy token) and
1583
+ // the SERVER runs the action against the extension using the grant it holds.
1584
+ turnBridgeUrl = String(payload.browserExtProxy.url);
1585
+ turnBridgeToken = String(payload.browserExtProxy.token);
1586
+ browserTargetRef = String(payload.browserExtProxy.targetRef || "");
1587
+ if (opts.verbose)
1588
+ console.log(chalk.gray(` [browser-bridge] ext-proxy turn (session=${payload.sessionId.slice(0, 10)} url=${turnBridgeUrl})`));
1589
+ }
1572
1590
  // Capture for the stdout "done" handler closure below.
1573
1591
  const _turnBridgeRunId = turnBridgeRunId;
1574
1592
  const _turnId = payload.turnId;
@@ -1617,9 +1635,13 @@ export async function connect(opts) {
1617
1635
  // JWS string so the Python browser toolkit can verify it again
1618
1636
  // (defence-in-depth) and install MEL_BROWSER_TURN_GRANT in the
1619
1637
  // host env for THIS turn only. Absent when no grant was provided.
1620
- ...(verifiedGrantToken ? { browser_grant: verifiedGrantToken, browser_target_ref: browserTargetRef ?? "" } : {}),
1638
+ ...(verifiedGrantToken
1639
+ ? { browser_grant: verifiedGrantToken, browser_target_ref: browserTargetRef ?? "" }
1640
+ // Front 3 EXTENSION proxy: no grant, but the host must still apply
1641
+ // MEL_BROWSER_URL/TOKEN + register the browser toolkit for the turn.
1642
+ : (turnBridgeUrl ? { browser_ext_proxy: true, browser_target_ref: browserTargetRef ?? "" } : {})),
1621
1643
  // Bridge URL + token for the host process env (applied per-turn by
1622
- // _apply_browser_turn_grant alongside the grant token).
1644
+ // _apply_browser_turn_grant, alongside the grant token OR the proxy flag).
1623
1645
  ...(turnBridgeUrl && turnBridgeToken
1624
1646
  ? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
1625
1647
  : {}),
@@ -0,0 +1,5 @@
1
+ /** Classify a control's accessible name into a consequential effect.
2
+ * Purchase wins on overlap. Returns null when nothing matches (the caller
3
+ * then runs the action normally, no gate). The name is lowercased + trimmed
4
+ * here so callers can pass a raw accessible name. */
5
+ export declare function classifyControlEffect(name: string): "publish" | "purchase" | null;
@@ -0,0 +1,87 @@
1
+ // packages/runner/src/publishGate.ts
2
+ //
3
+ // Melaya Browser, Route 1 HITL publish gate: the runner-side mirror of the
4
+ // phone's on-device `detectPublish` gate. The bridge is the only place that
5
+ // sees the clicked control's TRUSTED accessible name (page text is untrusted,
6
+ // so the server can only classify a raw "Post/Comment/Buy" click as
7
+ // "message"). This module classifies that trusted name into a consequential
8
+ // effect ("publish" | "purchase") so the bridge can escalate the effect from
9
+ // its own read and stage a human approval when the grant does not permit the
10
+ // action unattended.
11
+ //
12
+ // Pure classification only: no I/O, no Playwright. The bridge owns the
13
+ // accessible-name read and the server approval call; this file just maps a
14
+ // name string to an effect class using the frozen word lists.
15
+ // ---------------------------------------------------------------------
16
+ // Frozen effect vocabularies (phone detectPublish mirror)
17
+ // ---------------------------------------------------------------------
18
+ //
19
+ // Each phrase is matched word-aware (word boundaries) so "reporter" does not
20
+ // match "report" and "password" does not match "pass". Purchase wins on any
21
+ // overlap with publish. Keep these lists in sync with the phone's
22
+ // detectPublish word lists.
23
+ const PURCHASE_PHRASES = [
24
+ "buy",
25
+ "buy now",
26
+ "pay",
27
+ "pay now",
28
+ "place order",
29
+ "order",
30
+ "checkout",
31
+ "check out",
32
+ "purchase",
33
+ "subscribe",
34
+ "donate",
35
+ "confirm and pay",
36
+ "complete purchase",
37
+ "place bid",
38
+ ];
39
+ const PUBLISH_PHRASES = [
40
+ "post",
41
+ "comment",
42
+ "reply",
43
+ "send",
44
+ "publish",
45
+ "share",
46
+ "tweet",
47
+ "retweet",
48
+ "repost",
49
+ "submit",
50
+ "post comment",
51
+ "add comment",
52
+ "leave a comment",
53
+ "send message",
54
+ ];
55
+ /** Escape a phrase for use inside a RegExp source. */
56
+ function escapeRe(s) {
57
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
58
+ }
59
+ /** Word-aware containment: true when `phrase` appears in `name` bounded by
60
+ * non-word characters (or string ends) on both sides. Both are assumed
61
+ * already lowercased + trimmed. A phrase with internal spaces matches the
62
+ * same run of whitespace-separated words. */
63
+ function containsPhrase(name, phrase) {
64
+ // \b would not fire around non-ASCII, but the frozen phrases are all ASCII
65
+ // words, so an explicit non-word-char boundary on each side is sufficient
66
+ // and avoids the "reporter" != "report" / "password" != "pass" traps.
67
+ const re = new RegExp(`(?:^|[^a-z0-9])${escapeRe(phrase)}(?:$|[^a-z0-9])`, "i");
68
+ return re.test(name);
69
+ }
70
+ /** Classify a control's accessible name into a consequential effect.
71
+ * Purchase wins on overlap. Returns null when nothing matches (the caller
72
+ * then runs the action normally, no gate). The name is lowercased + trimmed
73
+ * here so callers can pass a raw accessible name. */
74
+ export function classifyControlEffect(name) {
75
+ const n = String(name || "").toLowerCase().trim();
76
+ if (!n)
77
+ return null;
78
+ for (const phrase of PURCHASE_PHRASES) {
79
+ if (containsPhrase(n, phrase))
80
+ return "purchase";
81
+ }
82
+ for (const phrase of PUBLISH_PHRASES) {
83
+ if (containsPhrase(n, phrase))
84
+ return "publish";
85
+ }
86
+ return null;
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,