@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.
- package/dist/assistantHost.py +12 -1
- package/dist/browserBridge.d.ts +2 -0
- package/dist/browserBridge.js +175 -0
- package/dist/connection.js +8 -0
- package/dist/publishGate.d.ts +5 -0
- package/dist/publishGate.js +87 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/browserBridge.d.ts
CHANGED
package/dist/browserBridge.js
CHANGED
|
@@ -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
|
}
|
package/dist/connection.js
CHANGED
|
@@ -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
|
+
}
|