@melaya/runner 1.1.17 → 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.
@@ -712,7 +712,18 @@ def _register_stream_hooks(agent) -> None:
712
712
  return
713
713
  prev = _stream["lens"].get(mid, 0)
714
714
  if len(text) > prev:
715
- _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:])
716
727
  _stream["lens"][mid] = len(text)
717
728
  except Exception:
718
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;
@@ -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.17",
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,