@melaya/runner 1.1.29 → 1.1.31
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/browserBridge.d.ts +6 -0
- package/dist/browserBridge.js +154 -35
- package/dist/cli.js +75 -13
- package/dist/connection.js +61 -2
- package/dist/lumaBrowserBridge.js +9 -1
- package/dist/publishGate.d.ts +14 -13
- package/dist/publishGate.js +131 -79
- package/dist/pythonEnv.js +91 -7
- package/package.json +1 -1
package/dist/browserBridge.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface BrowserRunSpec {
|
|
|
12
12
|
headless?: boolean;
|
|
13
13
|
serverBase?: string;
|
|
14
14
|
runAuthHeader?: string;
|
|
15
|
+
hitlMode?: string;
|
|
15
16
|
}
|
|
16
17
|
export interface BrowserBridge {
|
|
17
18
|
url: string;
|
|
@@ -25,6 +26,11 @@ export interface BrowserBridge {
|
|
|
25
26
|
* releases leases, closes ONLY owned contexts, forgets the grant. */
|
|
26
27
|
teardownRun(runId: string, reason: string): Promise<void>;
|
|
27
28
|
teardownAll(reason: string): Promise<void>;
|
|
29
|
+
/** Tear down every per-turn bridge run belonging to an assistant session
|
|
30
|
+
* (runId form `${sessionId}:${turnId}`). Called when a host process exits or
|
|
31
|
+
* crashes so a turn that never sent its `done` frame does not leak its lease
|
|
32
|
+
* and its enforceOnPage route handlers onto the user's still-open tab. */
|
|
33
|
+
teardownRunsForSession(sessionId: string, reason: string): Promise<void>;
|
|
28
34
|
hasRun(runId: string): boolean;
|
|
29
35
|
/**
|
|
30
36
|
* Launch a headed browser as an interactive (grant-free, read-only-watch)
|
package/dist/browserBridge.js
CHANGED
|
@@ -39,6 +39,18 @@ import { SessionManager, SessionError, spaceUserDataDir, } from "./sessionManage
|
|
|
39
39
|
import { ensureEngine } from "./browserProvisioner.js";
|
|
40
40
|
import { runSandboxedScript } from "./codeWorker.js";
|
|
41
41
|
import { classifyControlEffect, writeGatedInMode } from "./publishGate.js";
|
|
42
|
+
// A wall-clock around a CDP command. Playwright's CDPSession.send has NO default
|
|
43
|
+
// timeout, so a wedged/busy renderer means the promise NEVER settles (not a
|
|
44
|
+
// rejection - no catch fires) and the lease op-chain queues every later op
|
|
45
|
+
// behind it forever. Same stall class the screenshot path already bounds. Note
|
|
46
|
+
// the underlying CDP command stays pending beneath the race; this only unblocks
|
|
47
|
+
// the caller so the tool returns a typed error instead of hanging.
|
|
48
|
+
function cdpRace(p, what, ms = 20_000) {
|
|
49
|
+
return Promise.race([
|
|
50
|
+
p,
|
|
51
|
+
new Promise((_, reject) => setTimeout(() => reject(new BridgeError("cdp_timeout", `${what} did not respond in ${ms}ms`)), ms)),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
42
54
|
// ---------------------------------------------------------------------
|
|
43
55
|
// Limits and constants
|
|
44
56
|
// ---------------------------------------------------------------------
|
|
@@ -841,13 +853,13 @@ export async function startBrowserBridge(opts) {
|
|
|
841
853
|
async function collectFromTarget(cdp, frameKey, frameOffset, viewport, out) {
|
|
842
854
|
const STYLE_FILTER = ["display", "visibility", "opacity"];
|
|
843
855
|
const [snap, ax] = await Promise.all([
|
|
844
|
-
cdp.send("DOMSnapshot.captureSnapshot", {
|
|
856
|
+
cdpRace(cdp.send("DOMSnapshot.captureSnapshot", {
|
|
845
857
|
computedStyles: STYLE_FILTER,
|
|
846
858
|
includePaintOrder: true,
|
|
847
|
-
}),
|
|
859
|
+
}), "DOMSnapshot.captureSnapshot", 30_000),
|
|
848
860
|
(async () => {
|
|
849
861
|
await cdp.send("Accessibility.enable").catch(() => { });
|
|
850
|
-
return cdp.send("Accessibility.getFullAXTree", {});
|
|
862
|
+
return cdpRace(cdp.send("Accessibility.getFullAXTree", {}), "Accessibility.getFullAXTree", 30_000);
|
|
851
863
|
})(),
|
|
852
864
|
]);
|
|
853
865
|
const strings = snap.strings;
|
|
@@ -1046,7 +1058,7 @@ export async function startBrowserBridge(opts) {
|
|
|
1046
1058
|
}
|
|
1047
1059
|
try {
|
|
1048
1060
|
await localCdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: binding.backendNodeId }).catch(() => { });
|
|
1049
|
-
const bm = await localCdp.send("DOM.getBoxModel", { backendNodeId: binding.backendNodeId });
|
|
1061
|
+
const bm = await cdpRace(localCdp.send("DOM.getBoxModel", { backendNodeId: binding.backendNodeId }), "DOM.getBoxModel");
|
|
1050
1062
|
const q = bm.model.content; // 4 corner points, viewport coords of the owning target
|
|
1051
1063
|
const cx = (q[0] + q[2] + q[4] + q[6]) / 4 + offset.x;
|
|
1052
1064
|
const cy = (q[1] + q[3] + q[5] + q[7]) / 4 + offset.y;
|
|
@@ -1088,8 +1100,47 @@ export async function startBrowserBridge(opts) {
|
|
|
1088
1100
|
* (button, a, [role=button], [type=submit]) and reads, in precedence order:
|
|
1089
1101
|
* aria-label, else innerText/textContent, else value, else title. Returns a
|
|
1090
1102
|
* lowercased + trimmed string (empty when nothing is readable). Never
|
|
1091
|
-
* throws: on any page error it returns "" so the caller
|
|
1092
|
-
*
|
|
1103
|
+
* throws: on any page error it returns a SENTINEL (not "") so the caller can
|
|
1104
|
+
* fail closed, distinct from a real element that legitimately has no name. */
|
|
1105
|
+
/** OWASP A06 (defense-in-depth) — server-side secret-field detection. Returns
|
|
1106
|
+
* true when the field the agent is about to type into (the focused element,
|
|
1107
|
+
* or the element at the resolved point) is a password / OTP / payment input.
|
|
1108
|
+
* The tool contract already forbids the agent from typing credentials; this
|
|
1109
|
+
* makes it an ENFORCED control, not just a prompt: input_text refuses and the
|
|
1110
|
+
* agent must hand off to the human. Fails OPEN (returns false) on a page read
|
|
1111
|
+
* error — the threat is the agent mistakenly typing a secret, not a hostile
|
|
1112
|
+
* page, and failing closed would let a page block all legitimate typing. */
|
|
1113
|
+
async function isSecretFieldAtPoint(page, x, y) {
|
|
1114
|
+
try {
|
|
1115
|
+
return await page.evaluate(([px, py]) => {
|
|
1116
|
+
const isSecret = (node) => {
|
|
1117
|
+
const el = node;
|
|
1118
|
+
if (!el || !el.tagName)
|
|
1119
|
+
return false;
|
|
1120
|
+
const tag = el.tagName.toLowerCase();
|
|
1121
|
+
if (tag !== "input" && tag !== "textarea")
|
|
1122
|
+
return false;
|
|
1123
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
1124
|
+
if (type === "password")
|
|
1125
|
+
return true;
|
|
1126
|
+
const ac = (el.getAttribute("autocomplete") || "").toLowerCase();
|
|
1127
|
+
if (/(^|\s)(current-password|new-password|one-time-code|cc-number|cc-csc)(\s|$)/.test(ac))
|
|
1128
|
+
return true;
|
|
1129
|
+
const hay = [
|
|
1130
|
+
el.getAttribute("name"), el.getAttribute("id"),
|
|
1131
|
+
el.getAttribute("aria-label"), el.getAttribute("placeholder"),
|
|
1132
|
+
].map((s) => (s || "").toLowerCase()).join(" ");
|
|
1133
|
+
return /pass(word|code)?|\botp\b|one.?time|\bcvv\b|\bcvc\b|card.?number|security.?code|\bpin\b|secret|seed.?phrase|recovery.?(code|phrase)/.test(hay);
|
|
1134
|
+
};
|
|
1135
|
+
const atPoint = document.elementFromPoint(px, py);
|
|
1136
|
+
return isSecret(document.activeElement) || isSecret(atPoint);
|
|
1137
|
+
}, [x, y]);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const NAME_READ_FAILED = " name_read_failed";
|
|
1093
1144
|
async function readAccessibleNameAtPoint(page, x, y) {
|
|
1094
1145
|
try {
|
|
1095
1146
|
const raw = await page.evaluate(([px, py]) => {
|
|
@@ -1118,7 +1169,13 @@ export async function startBrowserBridge(opts) {
|
|
|
1118
1169
|
return String(raw || "").toLowerCase().trim();
|
|
1119
1170
|
}
|
|
1120
1171
|
catch {
|
|
1121
|
-
|
|
1172
|
+
// SENTINEL, not "". A thrown page.evaluate (crashed frame, nav race, or a
|
|
1173
|
+
// HOSTILE page that overrode elementFromPoint/innerText to throw) must NOT
|
|
1174
|
+
// read as "benign control" — that is how a page could strip HITL from its
|
|
1175
|
+
// own Buy button. The caller fails closed on this value; a real element
|
|
1176
|
+
// with no name still returns "" and keeps the by-design fail-open for
|
|
1177
|
+
// icon-only buttons.
|
|
1178
|
+
return NAME_READ_FAILED;
|
|
1122
1179
|
}
|
|
1123
1180
|
}
|
|
1124
1181
|
/** Stage a human approval on the server for a consequential click/tap and
|
|
@@ -1197,16 +1254,38 @@ export async function startBrowserBridge(opts) {
|
|
|
1197
1254
|
* normally (falls through to execution) when there is nothing to gate or
|
|
1198
1255
|
* the approval came back "approved". Applies to click/dblclick/tap only;
|
|
1199
1256
|
* callers must not invoke it for hover. */
|
|
1200
|
-
async function enforcePublishGate(reg, page, kind, name, ref, mode) {
|
|
1201
|
-
|
|
1257
|
+
async function enforcePublishGate(reg, page, kind, name, ref, mode, knownEffect) {
|
|
1258
|
+
// Effect resolution, in order:
|
|
1259
|
+
// 1. knownEffect — the action is a commit by CONSTRUCTION (browser_submit
|
|
1260
|
+
// submits a form; there is no reliable name to read), so the caller
|
|
1261
|
+
// names the effect directly. Previously submit passed name="" and was
|
|
1262
|
+
// never gated at all.
|
|
1263
|
+
// 2. name read FAILED — fail CLOSED. An unreadable control in a gated mode
|
|
1264
|
+
// is treated as a purchase (the most-gated family, so it gates in both
|
|
1265
|
+
// safe and payments_only). This is what stops a hostile page from
|
|
1266
|
+
// throwing in the name reader to strip its own HITL card.
|
|
1267
|
+
// 3. otherwise classify the name as before.
|
|
1268
|
+
const escalated = knownEffect
|
|
1269
|
+
?? (name === NAME_READ_FAILED ? "purchase" : classifyControlEffect(name));
|
|
1202
1270
|
if (!escalated)
|
|
1203
|
-
return; //
|
|
1271
|
+
return; // benign control (real element, no consequential verb): run normally.
|
|
1204
1272
|
// Gate on the TURN's AUTONOMY MODE, NOT the grant ceiling: the ceiling is
|
|
1205
1273
|
// always 'destructive' (it bounds what is possible), so a checkEffect gate
|
|
1206
1274
|
// never fires. Autonomous => act; safe => gate every write; payments_only =>
|
|
1207
1275
|
// gate only purchases. The mode arrives per act on the bridge body
|
|
1208
1276
|
// (browser.py stamps hitl_mode); absent fails closed to safe.
|
|
1209
|
-
|
|
1277
|
+
//
|
|
1278
|
+
// OWASP A01 — bind to the registered spec mode: the body may only TIGHTEN.
|
|
1279
|
+
// A caller holding MEL_BROWSER_TOKEN could POST hitl_mode:"autonomous" to
|
|
1280
|
+
// strip the gate; the registered (server-clamped) mode wins unless the body
|
|
1281
|
+
// is MORE restrictive. Spec absent => defer to body (unchanged behavior).
|
|
1282
|
+
const _modeRank = (m) => m === "autonomous" ? 2 : m === "payments_only" ? 1 : 0;
|
|
1283
|
+
const specMode = reg.spec.hitlMode;
|
|
1284
|
+
let effMode = mode;
|
|
1285
|
+
if (specMode != null) {
|
|
1286
|
+
effMode = mode != null && _modeRank(mode) < _modeRank(specMode) ? mode : specMode;
|
|
1287
|
+
}
|
|
1288
|
+
if (!writeGatedInMode(escalated, effMode))
|
|
1210
1289
|
return;
|
|
1211
1290
|
// Gated in this mode: stage a server approval so the card shows + the tool waits.
|
|
1212
1291
|
let origin = "";
|
|
@@ -1468,6 +1547,14 @@ export async function startBrowserBridge(opts) {
|
|
|
1468
1547
|
if (text.length > 20_000)
|
|
1469
1548
|
throw new BridgeError("act_args_invalid", "text exceeds 20000 chars");
|
|
1470
1549
|
const pt = await resolveActionPoint(reg, rec, lease, args);
|
|
1550
|
+
// OWASP A06 — refuse to type into a password / OTP / payment field.
|
|
1551
|
+
// Typing a credential is never permitted, in any autonomy mode; the
|
|
1552
|
+
// agent must hand off to the human (browser_ask_user). Enforced here
|
|
1553
|
+
// on the runner, not just promised in the tool prompt.
|
|
1554
|
+
if (await isSecretFieldAtPoint(page, pt.x, pt.y)) {
|
|
1555
|
+
throw new BridgeError("secret_field_blocked", "refused: the target looks like a password / OTP / payment field. " +
|
|
1556
|
+
"Hand off to the human with browser_ask_user so they enter the secret; never type credentials.");
|
|
1557
|
+
}
|
|
1471
1558
|
await humanClick(page, pt.x, pt.y);
|
|
1472
1559
|
// Clear-then-type: select-all + humanized type preserves site key handlers.
|
|
1473
1560
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => { });
|
|
@@ -1479,6 +1566,17 @@ export async function startBrowserBridge(opts) {
|
|
|
1479
1566
|
const key = String(args.key || "");
|
|
1480
1567
|
if (!/^[A-Za-z0-9+]{1,32}$/.test(key))
|
|
1481
1568
|
throw new BridgeError("act_args_invalid", `invalid key '${key}'`);
|
|
1569
|
+
// OWASP A01 — a commit key submits the focused form, exactly like
|
|
1570
|
+
// clicking a submit control; the server/extension transport already
|
|
1571
|
+
// classifies Enter/NumpadEnter as `publish` (browserEffects.ts
|
|
1572
|
+
// COMMIT_KEYS). Gate it here too so safe mode raises an approval card
|
|
1573
|
+
// instead of letting the model bypass the gate via "press Enter to
|
|
1574
|
+
// submit" — the very path the gated-submit error message used to
|
|
1575
|
+
// steer the model onto. Parity with the other transport, not new
|
|
1576
|
+
// friction. Fails closed to a `publish` effect by construction.
|
|
1577
|
+
if (key === "Enter" || key === "NumpadEnter") {
|
|
1578
|
+
await enforcePublishGate(reg, page, kind, "focused field", `press_key:${key}`, args.hitl_mode, "publish");
|
|
1579
|
+
}
|
|
1482
1580
|
await page.keyboard.press(key);
|
|
1483
1581
|
return { pressed: key };
|
|
1484
1582
|
}
|
|
@@ -1525,12 +1623,12 @@ export async function startBrowserBridge(opts) {
|
|
|
1525
1623
|
// Scope to the element's innerText via CDP object resolution.
|
|
1526
1624
|
const binding = sessions.resolveRef(lease, args.ref);
|
|
1527
1625
|
const cdp = await getCdp(rec, page);
|
|
1528
|
-
const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId });
|
|
1529
|
-
const result = await cdp.send("Runtime.callFunctionOn", {
|
|
1626
|
+
const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
|
|
1627
|
+
const result = await cdpRace(cdp.send("Runtime.callFunctionOn", {
|
|
1530
1628
|
objectId: resolved.object.objectId,
|
|
1531
1629
|
functionDeclaration: `function(){ return (this.innerText || this.textContent || "").trim(); }`,
|
|
1532
1630
|
returnByValue: true,
|
|
1533
|
-
});
|
|
1631
|
+
}), "Runtime.callFunctionOn");
|
|
1534
1632
|
text = String(result.result.value ?? "");
|
|
1535
1633
|
}
|
|
1536
1634
|
else {
|
|
@@ -1557,12 +1655,12 @@ export async function startBrowserBridge(opts) {
|
|
|
1557
1655
|
const binding = sessions.resolveRef(lease, args.ref);
|
|
1558
1656
|
const value = String(args.value ?? "");
|
|
1559
1657
|
const cdp = await getCdp(rec, page);
|
|
1560
|
-
const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId });
|
|
1561
|
-
await cdp.send("Runtime.callFunctionOn", {
|
|
1658
|
+
const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
|
|
1659
|
+
await cdpRace(cdp.send("Runtime.callFunctionOn", {
|
|
1562
1660
|
objectId: resolved.object.objectId,
|
|
1563
1661
|
functionDeclaration: `function(v){ this.value = v; this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); }`,
|
|
1564
1662
|
arguments: [{ value }],
|
|
1565
|
-
});
|
|
1663
|
+
}), "Runtime.callFunctionOn");
|
|
1566
1664
|
return { selected: value };
|
|
1567
1665
|
}
|
|
1568
1666
|
case "wait": {
|
|
@@ -1577,7 +1675,9 @@ export async function startBrowserBridge(opts) {
|
|
|
1577
1675
|
// sees the submission. submit() is only the fallback for the few
|
|
1578
1676
|
// forms that predate it.
|
|
1579
1677
|
const ref = args.ref ? String(args.ref) : "";
|
|
1580
|
-
|
|
1678
|
+
// Submitting a form IS a write commit — gate it as `publish`
|
|
1679
|
+
// unconditionally, whatever the (unreadable) submit control is called.
|
|
1680
|
+
await enforcePublishGate(reg, page, kind, "", ref || "focused form", args.hitl_mode, "publish");
|
|
1581
1681
|
const FIND_AND_SUBMIT = "function(){ var el = this; var f = el && el.closest ? el.closest('form') : null; " +
|
|
1582
1682
|
"if (!f) return false; if (typeof f.requestSubmit === 'function') f.requestSubmit(); " +
|
|
1583
1683
|
"else f.submit(); return true; }";
|
|
@@ -1585,23 +1685,23 @@ export async function startBrowserBridge(opts) {
|
|
|
1585
1685
|
let submitted = false;
|
|
1586
1686
|
if (ref) {
|
|
1587
1687
|
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", {
|
|
1688
|
+
const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
|
|
1689
|
+
const r = await cdpRace(cdp.send("Runtime.callFunctionOn", {
|
|
1590
1690
|
objectId: resolved.object.objectId,
|
|
1591
1691
|
functionDeclaration: FIND_AND_SUBMIT,
|
|
1592
1692
|
returnByValue: true,
|
|
1593
|
-
});
|
|
1693
|
+
}), "Runtime.callFunctionOn");
|
|
1594
1694
|
submitted = r.result?.value === true;
|
|
1595
1695
|
}
|
|
1596
1696
|
else {
|
|
1597
1697
|
// Evaluated as a string through CDP rather than page.evaluate so
|
|
1598
1698
|
// this file needs no DOM lib in its tsconfig.
|
|
1599
|
-
const r = await cdp.send("Runtime.evaluate", {
|
|
1699
|
+
const r = await cdpRace(cdp.send("Runtime.evaluate", {
|
|
1600
1700
|
returnByValue: true,
|
|
1601
1701
|
expression: "(function(){ var el = document.activeElement; var f = el && el.closest ? el.closest('form') : null; " +
|
|
1602
1702
|
"if (!f) return false; if (typeof f.requestSubmit === 'function') f.requestSubmit(); " +
|
|
1603
1703
|
"else f.submit(); return true; })()",
|
|
1604
|
-
});
|
|
1704
|
+
}), "Runtime.evaluate");
|
|
1605
1705
|
submitted = r.result?.value === true;
|
|
1606
1706
|
}
|
|
1607
1707
|
if (!submitted) {
|
|
@@ -1639,21 +1739,31 @@ export async function startBrowserBridge(opts) {
|
|
|
1639
1739
|
};
|
|
1640
1740
|
const name = await readAccessibleNameAtPoint(page, from.x, from.y);
|
|
1641
1741
|
await enforcePublishGate(reg, page, kind, name, String(args.ref ?? `${Math.round(from.x)},${Math.round(from.y)}`), args.hitl_mode);
|
|
1742
|
+
// Pure, declared before the try so `path.length` is in scope for the
|
|
1743
|
+
// return below.
|
|
1744
|
+
const path = buildDragPath(from, to, Math.round(moveMs / 25), moveMs, shape);
|
|
1642
1745
|
await page.mouse.move(from.x, from.y);
|
|
1643
1746
|
await page.mouse.down();
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1747
|
+
// Guarantee the button is released even if a move/wait throws mid-drag
|
|
1748
|
+
// (navigation, target detach). A left-pressed button turns every later
|
|
1749
|
+
// click into a drag-selection on the user's own headed browser, with
|
|
1750
|
+
// no visible cause. up() on an already-released button is harmless.
|
|
1751
|
+
try {
|
|
1752
|
+
// THE LONG PRESS. Button down, pointer still, nothing dispatched:
|
|
1753
|
+
// this interval is what the page uses to decide the item is picked
|
|
1754
|
+
// up, and it is the whole difference from a plain drag.
|
|
1755
|
+
await page.waitForTimeout(holdMs);
|
|
1756
|
+
for (const p of path) {
|
|
1757
|
+
await page.mouse.move(p.x, p.y);
|
|
1758
|
+
await page.waitForTimeout(p.dwellMs);
|
|
1759
|
+
}
|
|
1760
|
+
// Rest on the target before releasing: dragover-driven drop zones
|
|
1761
|
+
// need a frame or two at rest, and a release mid-motion misses them.
|
|
1762
|
+
await page.waitForTimeout(settleMs);
|
|
1763
|
+
}
|
|
1764
|
+
finally {
|
|
1765
|
+
await page.mouse.up().catch(() => { });
|
|
1652
1766
|
}
|
|
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
1767
|
return {
|
|
1658
1768
|
from: { x: Math.round(from.x), y: Math.round(from.y) },
|
|
1659
1769
|
to: { x: Math.round(to.x), y: Math.round(to.y) },
|
|
@@ -2478,6 +2588,15 @@ export async function startBrowserBridge(opts) {
|
|
|
2478
2588
|
registerRun,
|
|
2479
2589
|
teardownRun,
|
|
2480
2590
|
teardownAll,
|
|
2591
|
+
async teardownRunsForSession(sessionId, reason) {
|
|
2592
|
+
const prefix = `${sessionId}:`;
|
|
2593
|
+
// Snapshot the keys first — teardownRun mutates byRunId. Idempotent, so a
|
|
2594
|
+
// race with a normal turn-done teardown is harmless.
|
|
2595
|
+
const ids = [...byRunId.keys()].filter((k) => k.startsWith(prefix));
|
|
2596
|
+
for (const id of ids) {
|
|
2597
|
+
await teardownRun(id, reason).catch(() => { });
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2481
2600
|
hasRun(runId) {
|
|
2482
2601
|
return byRunId.has(runId);
|
|
2483
2602
|
},
|
package/dist/cli.js
CHANGED
|
@@ -53,13 +53,25 @@ async function main() {
|
|
|
53
53
|
const opts = program.opts();
|
|
54
54
|
console.log(BANNER);
|
|
55
55
|
// Detect Python
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
const py = await findPython();
|
|
57
|
+
if (!py.ok) {
|
|
58
|
+
if (py.foundVersion) {
|
|
59
|
+
console.log(chalk.red(` ✗ ${py.foundVersion} found, but Melaya needs Python 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}`));
|
|
60
|
+
console.log(chalk.gray(" Python 3.13+ has no wheels yet for the assistant's Python deps (agentscope + native"));
|
|
61
|
+
console.log(chalk.gray(" extensions), so the runner's venv can't build and every assistant turn fails."));
|
|
62
|
+
console.log(chalk.gray(" Install a supported Python, then restart the runner:"));
|
|
63
|
+
console.log(chalk.gray(" macOS: brew install python@3.12"));
|
|
64
|
+
console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
|
|
65
|
+
console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.log(chalk.red(" ✗ Python not found in PATH"));
|
|
69
|
+
console.log(chalk.gray(" Install Python 3.12 from https://python.org"));
|
|
70
|
+
}
|
|
60
71
|
process.exit(1);
|
|
61
72
|
}
|
|
62
|
-
|
|
73
|
+
const python = py.cmd;
|
|
74
|
+
console.log(chalk.green(` ✓ Python: ${python} (3.${py.minor})`));
|
|
63
75
|
// Detect local models
|
|
64
76
|
const models = await detectModels();
|
|
65
77
|
if (models.length === 0) {
|
|
@@ -81,17 +93,67 @@ async function main() {
|
|
|
81
93
|
verbose: opts.verbose,
|
|
82
94
|
});
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
// Supported CPython range for the runner's venv. agentscope and its native
|
|
97
|
+
// deps (tiktoken, numpy, pydantic-core, curl_cffi via scrapling, sentence-
|
|
98
|
+
// transformers, …) only ship wheels for 3.10–3.12. Building the venv from a
|
|
99
|
+
// 3.13 interpreter — which is now Homebrew's default `python3` — leaves those
|
|
100
|
+
// deps unbuilt, so `import agentscope` fails the probe and every assistant /
|
|
101
|
+
// pipeline turn dies with "agentscope import probe failed". So we must NOT
|
|
102
|
+
// grab the first `python3` on PATH: we rank interpreters and pick a supported
|
|
103
|
+
// minor, only reporting the unsupported one (with install guidance) if that's
|
|
104
|
+
// all that exists. Bump PY_MAX_MINOR once the dep set is validated on a newer
|
|
105
|
+
// CPython.
|
|
106
|
+
const PY_MIN_MINOR = 10;
|
|
107
|
+
const PY_MAX_MINOR = 12;
|
|
108
|
+
async function probePyVersion(cmd) {
|
|
85
109
|
const { execSync } = await import("child_process");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
110
|
+
try {
|
|
111
|
+
const out = execSync(`"${cmd}" --version`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
112
|
+
const m = out.match(/Python (\d+)\.(\d+)/);
|
|
113
|
+
if (!m)
|
|
114
|
+
return null;
|
|
115
|
+
return { major: Number(m[1]), minor: Number(m[2]) };
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function findPython() {
|
|
122
|
+
const candidates = [];
|
|
123
|
+
// 1. Explicit supported-minor binaries first, newest supported first. Homebrew
|
|
124
|
+
// (`brew install python@3.12`) and pyenv both expose these names on PATH,
|
|
125
|
+
// so this catches a supported interpreter even when bare `python3` is 3.13.
|
|
126
|
+
for (let m = PY_MAX_MINOR; m >= PY_MIN_MINOR; m--)
|
|
127
|
+
candidates.push(`python3.${m}`);
|
|
128
|
+
// 2. Common absolute install locations, in case PATH points `python3` at 3.13
|
|
129
|
+
// but a supported keg is installed and simply not linked onto PATH.
|
|
130
|
+
for (let m = PY_MAX_MINOR; m >= PY_MIN_MINOR; m--) {
|
|
131
|
+
if (process.platform === "darwin") {
|
|
132
|
+
candidates.push(`/opt/homebrew/opt/python@3.${m}/bin/python3.${m}`, // Apple-silicon brew keg
|
|
133
|
+
`/usr/local/opt/python@3.${m}/bin/python3.${m}`, // Intel brew keg
|
|
134
|
+
`/opt/homebrew/bin/python3.${m}`, `/usr/local/bin/python3.${m}`);
|
|
91
135
|
}
|
|
92
|
-
|
|
136
|
+
else if (process.platform === "linux") {
|
|
137
|
+
candidates.push(`/usr/bin/python3.${m}`, `/usr/local/bin/python3.${m}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// 3. Generic names last — accepted only if they resolve into the supported range.
|
|
141
|
+
candidates.push("python3", "python");
|
|
142
|
+
let fallback; // a real 3.x that exists but is out of range
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const cmd of candidates) {
|
|
145
|
+
if (seen.has(cmd))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(cmd);
|
|
148
|
+
const v = await probePyVersion(cmd);
|
|
149
|
+
if (!v || v.major !== 3)
|
|
150
|
+
continue;
|
|
151
|
+
if (v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR)
|
|
152
|
+
return { ok: true, cmd, minor: v.minor };
|
|
153
|
+
if (!fallback)
|
|
154
|
+
fallback = `Python ${v.major}.${v.minor}`;
|
|
93
155
|
}
|
|
94
|
-
return
|
|
156
|
+
return { ok: false, foundVersion: fallback };
|
|
95
157
|
}
|
|
96
158
|
// `melaya-runner copilot login` — GitHub Copilot device-flow sign-in, cached
|
|
97
159
|
// locally. Handled before the runner arg-parse so it needs no --token.
|
package/dist/connection.js
CHANGED
|
@@ -820,6 +820,16 @@ export async function connect(opts) {
|
|
|
820
820
|
stderrTail.shift();
|
|
821
821
|
}
|
|
822
822
|
});
|
|
823
|
+
proc.on("error", (err) => {
|
|
824
|
+
// Spawn failure (interpreter missing/EACCES) emits "error", never "exit".
|
|
825
|
+
// Mirror the failure path so the run resolves instead of crashing the runner.
|
|
826
|
+
activeProcesses.delete(payload.runId);
|
|
827
|
+
if (browserBridge && browserBridge.hasRun(payload.runId)) {
|
|
828
|
+
browserBridge.teardownRun(payload.runId, "run_spawn_error").catch(() => { });
|
|
829
|
+
}
|
|
830
|
+
console.log(chalk.red(` ✗ pipeline spawn failed: ${err?.message || err}`));
|
|
831
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
832
|
+
});
|
|
823
833
|
proc.on("exit", (code) => {
|
|
824
834
|
activeProcesses.delete(payload.runId);
|
|
825
835
|
// Melaya Browser terminal path (plan Section 7): the pipeline
|
|
@@ -874,7 +884,28 @@ export async function connect(opts) {
|
|
|
874
884
|
});
|
|
875
885
|
}
|
|
876
886
|
catch (e) {
|
|
877
|
-
|
|
887
|
+
// Name the cause. Every other failure branch emits an agent_message the
|
|
888
|
+
// user can read; this outer catch (shared-module fetch, file writes, grant
|
|
889
|
+
// plumbing) sent a bare status:"failed" with nothing on screen. `e.message`
|
|
890
|
+
// is undefined for a thrown string, so fall back to the value + type.
|
|
891
|
+
const reason = e?.message || String(e) || (e?.name ? `${e.name} error` : "unknown error");
|
|
892
|
+
console.log(chalk.red(` ✗ Run failed: ${reason}`));
|
|
893
|
+
socket.emit("runner:event", {
|
|
894
|
+
run_id: payload.runId,
|
|
895
|
+
event_type: "agent_message",
|
|
896
|
+
project: payload.project,
|
|
897
|
+
replyId: `run-failed-${payload.runId}`,
|
|
898
|
+
replyName: "Runner",
|
|
899
|
+
replyRole: "system",
|
|
900
|
+
msg: {
|
|
901
|
+
id: `run-failed-${payload.runId}`,
|
|
902
|
+
name: "Runner",
|
|
903
|
+
role: "system",
|
|
904
|
+
content: [{ type: "text", text: `Run failed before it could start: ${reason}` }],
|
|
905
|
+
metadata: { runStartError: true },
|
|
906
|
+
timestamp: new Date().toISOString(),
|
|
907
|
+
},
|
|
908
|
+
});
|
|
878
909
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
879
910
|
}
|
|
880
911
|
});
|
|
@@ -1423,8 +1454,24 @@ export async function connect(opts) {
|
|
|
1423
1454
|
// (e.g. the user added a connector) kills this host and spawns a REPLACEMENT
|
|
1424
1455
|
// under the same sid; if this late exit deleted the map entry blindly it
|
|
1425
1456
|
// would orphan the newcomer and make the next turn "session_not_found".
|
|
1426
|
-
|
|
1457
|
+
proc.on("error", (err) => {
|
|
1458
|
+
// A spawn that fails (python deleted post-check, EACCES/AV) emits "error"
|
|
1459
|
+
// and never "exit"; with no listener this took down the WHOLE runner.
|
|
1460
|
+
if (activeAssistants.get(sid) === session)
|
|
1461
|
+
activeAssistants.delete(sid);
|
|
1462
|
+
bootingAssistants.delete(sid);
|
|
1463
|
+
browserBridge?.teardownRunsForSession(sid, "assistant_spawn_error").catch(() => { });
|
|
1464
|
+
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
|
|
1465
|
+
});
|
|
1466
|
+
const weOwned = activeAssistants.get(sid) === session;
|
|
1467
|
+
if (weOwned)
|
|
1427
1468
|
activeAssistants.delete(sid);
|
|
1469
|
+
// Tear down any per-turn bridge runs this host leaked (a turn that never
|
|
1470
|
+
// sent `done` before the host exited/crashed) so its lease + enforceOnPage
|
|
1471
|
+
// handlers don't linger on the user's open tab. Only if a replacement
|
|
1472
|
+
// host hasn't already taken over the sid.
|
|
1473
|
+
if (weOwned)
|
|
1474
|
+
browserBridge?.teardownRunsForSession(sid, `assistant_exit:${code}`).catch(() => { });
|
|
1428
1475
|
// A host we INTENTIONALLY killed for a config-drift reboot must NOT emit
|
|
1429
1476
|
// session_closed — that frame would fail the fresh boot's ready waiter
|
|
1430
1477
|
// (the server can't pre-mark it superseded here, the kill is runner-side).
|
|
@@ -1552,6 +1599,10 @@ export async function connect(opts) {
|
|
|
1552
1599
|
// frame POST path uses (ws/wss -> http/https).
|
|
1553
1600
|
serverBase: opts.serverUrl.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://")),
|
|
1554
1601
|
runAuthHeader: `Bearer ${opts.token}`,
|
|
1602
|
+
// OWASP A01 — bind the server-clamped turn mode into the spec so
|
|
1603
|
+
// the per-act bridge body can only TIGHTEN it, never loosen it via
|
|
1604
|
+
// a forged hitl_mode:"autonomous".
|
|
1605
|
+
hitlMode,
|
|
1555
1606
|
};
|
|
1556
1607
|
const { token } = bridge.registerRun(spec);
|
|
1557
1608
|
turnBridgeRunId = turnRunId;
|
|
@@ -1977,6 +2028,10 @@ export async function connect(opts) {
|
|
|
1977
2028
|
proc.stderr.on("data", (data) => {
|
|
1978
2029
|
stderrBuf += data.toString("utf-8");
|
|
1979
2030
|
});
|
|
2031
|
+
proc.on("error", (err) => {
|
|
2032
|
+
console.log(chalk.red(` ✗ RAG ingest spawn failed: ${err?.message || err}`));
|
|
2033
|
+
socket.emit("rag:ingest-result", { session_id: sid, ok: false, error: err?.message || String(err), store_path: storePath });
|
|
2034
|
+
});
|
|
1980
2035
|
proc.on("close", (code) => {
|
|
1981
2036
|
const ok = code === 0;
|
|
1982
2037
|
console.log(ok
|
|
@@ -2084,6 +2139,10 @@ export async function connect(opts) {
|
|
|
2084
2139
|
let stderr = "";
|
|
2085
2140
|
proc.stdout.on("data", (data) => { stdout += data.toString("utf-8"); });
|
|
2086
2141
|
proc.stderr.on("data", (data) => { stderr += data.toString("utf-8"); });
|
|
2142
|
+
proc.on("error", (err) => {
|
|
2143
|
+
// Spawn failure emits "error", never "close"; resolve the retrieve so the UI stops spinning.
|
|
2144
|
+
socket.emit("rag:retrieve-result", { session_id: sid, ok: false, results: [], error: err?.message || String(err) });
|
|
2145
|
+
});
|
|
2087
2146
|
proc.on("close", (code) => {
|
|
2088
2147
|
const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
|
|
2089
2148
|
let parsed = null;
|
|
@@ -330,9 +330,13 @@ export async function startLumaBrowserBridge(opts) {
|
|
|
330
330
|
b = await playwright.chromium.launch({ headless: true, args: launchArgs });
|
|
331
331
|
}
|
|
332
332
|
browser = b;
|
|
333
|
-
browserLaunching = null;
|
|
334
333
|
return b;
|
|
335
334
|
})();
|
|
335
|
+
// Clear the cached promise on FAILURE too, or a single failed launch
|
|
336
|
+
// (bundled Chromium never installed) is cached and every later call returns
|
|
337
|
+
// the same stale rejection until the runner restarts — Luma writes stay
|
|
338
|
+
// broken even after the user fixes the browser. Mirrors browserBridge.ts:808.
|
|
339
|
+
browserLaunching.then(() => { browserLaunching = null; }, () => { browserLaunching = null; });
|
|
336
340
|
return browserLaunching;
|
|
337
341
|
}
|
|
338
342
|
async function _readStorageState() {
|
|
@@ -403,6 +407,10 @@ export async function startLumaBrowserBridge(opts) {
|
|
|
403
407
|
};
|
|
404
408
|
if (init.body !== undefined)
|
|
405
409
|
fetchInit.body = init.body;
|
|
410
|
+
// Bound the in-page fetch: without a signal, a Cloudflare hold or a
|
|
411
|
+
// stalled response leaves this evaluate (and the /luma/forward tool
|
|
412
|
+
// call) hanging forever — a promise no catch can see.
|
|
413
|
+
fetchInit.signal = AbortSignal.timeout(30_000);
|
|
406
414
|
const r = await fetch(init.url, fetchInit);
|
|
407
415
|
const text = await r.text();
|
|
408
416
|
return { status: r.status, body: text };
|
package/dist/publishGate.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export declare
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
1
|
+
export type CommitEffect = "publish" | "purchase" | "account_change" | "destructive";
|
|
2
|
+
export declare const PURCHASE_VERBS: readonly ["buy", "buy now", "pay", "pay now", "place order", "order", "checkout", "check out", "purchase", "subscribe", "donate", "confirm and pay", "complete purchase", "place bid", "add to cart", "proceed to payment", "comprar", "pagar", "pagar ahora", "finalizar compra", "realizar pedido", "suscribirse", "donar", "confirmar y pagar", "finalizar pedido", "assinar", "acheter", "payer", "commander", "passer commande", "s'abonner", "abonner", "faire un don", "valider le paiement", "proceder au paiement", "купить", "оплатить", "заказать", "подписаться", "оформить заказ", "खरीदें", "भुगतान करें", "ऑर्डर करें", "सब्सक्राइब करें", "bumili", "magbayad", "购买", "付款", "立即购买", "结算", "提交订单", "订阅", "充值"];
|
|
3
|
+
export declare const DESTRUCTIVE_VERBS: readonly ["delete", "delete account", "remove", "erase", "wipe", "destroy", "discard", "revoke", "disconnect", "unlink", "deactivate", "close account", "terminate", "unsubscribe", "cancel subscription", "cancel plan", "archive", "trash", "reset", "restore defaults", "leave", "block", "ban", "uninstall", "clear all", "eliminar", "borrar", "quitar", "revocar", "desconectar", "desactivar", "cancelar suscripcion", "archivar", "restablecer", "excluir", "remover", "desativar", "cancelar assinatura", "arquivar", "redefinir", "supprimer", "effacer", "retirer", "revoquer", "deconnecter", "desactiver", "resilier", "archiver", "reinitialiser", "удалить", "стереть", "отключить", "отозвать", "деактивировать", "отменить подписку", "сбросить", "हटाएं", "डिलीट करें", "निकालें", "रद्द करें", "burahin", "alisin", "tanggalin", "删除", "移除", "清除", "销毁", "注销", "解绑", "取消订阅", "归档", "重置"];
|
|
4
|
+
export declare const ACCOUNT_VERBS: readonly ["change password", "update password", "reset password", "change email", "update email", "enable two-factor", "disable two-factor", "add member", "invite", "invite member", "transfer ownership", "make admin", "grant access", "authorize", "authorise", "allow access", "connect account", "link account", "add payment method", "withdraw", "transfer", "send money", "cambiar contrasena", "invitar", "autorizar", "conceder acceso", "transferir", "retirar", "alterar senha", "convidar", "autorizar acesso", "changer le mot de passe", "inviter", "autoriser", "transferer", "retirer", "сменить пароль", "пригласить", "разрешить", "перевести", "вывести", "पासवर्ड बदलें", "आमंत्रित करें", "अनुमति दें", "mag-imbita", "payagan", "修改密码", "邀请", "授权", "允许", "转让", "提现", "转账"];
|
|
5
|
+
export declare const PUBLISH_VERBS: readonly ["post", "comment", "reply", "send", "publish", "share", "tweet", "retweet", "repost", "submit", "post comment", "add comment", "leave a comment", "send message", "create", "save", "update", "apply", "confirm", "accept", "agree", "add", "upload", "book", "reserve", "sign", "sign up", "register", "follow", "like", "upvote", "download", "install", "deploy", "merge", "approve", "continue", "finish", "done", "next", "publicar", "comentar", "responder", "enviar", "compartir", "crear", "guardar", "actualizar", "aplicar", "confirmar", "aceptar", "anadir", "agregar", "seguir", "registrarse", "continuar", "criar", "salvar", "atualizar", "aceitar", "adicionar", "seguir", "continuar", "compartilhar", "publier", "commenter", "repondre", "envoyer", "partager", "creer", "enregistrer", "sauvegarder", "mettre a jour", "appliquer", "confirmer", "accepter", "ajouter", "suivre", "s'inscrire", "continuer", "valider", "опубликовать", "комментировать", "ответить", "отправить", "поделиться", "создать", "сохранить", "обновить", "применить", "подтвердить", "принять", "добавить", "подписаться", "продолжить", "зарегистрироваться", "पोस्ट करें", "टिप्पणी करें", "भेजें", "साझा करें", "बनाएं", "सहेजें", "अपडेट करें", "पुष्टि करें", "स्वीकार करें", "जोड़ें", "जारी रखें", "ipadala", "ibahagi", "gumawa", "i-save", "kumpirmahin", "tanggapin", "magdagdag", "magpatuloy", "sundan", "发布", "发送", "评论", "回复", "分享", "转发", "提交", "创建", "保存", "更新", "确认", "接受", "同意", "添加", "上传", "关注", "点赞", "继续", "注册", "完成"];
|
|
6
|
+
/** Classify a control name into a consequential effect, or null when benign.
|
|
7
|
+
* Precedence by blast radius: purchase, destructive, account_change, publish. */
|
|
8
|
+
export declare function classifyControlEffect(name: string): CommitEffect | null;
|
|
9
|
+
/** Does this effect need approval in the given autonomy mode?
|
|
10
|
+
* safe -> gate every consequential write
|
|
11
|
+
* payments_only -> gate purchases AND the two irreversible families
|
|
12
|
+
* autonomous -> gate nothing
|
|
13
|
+
* Unknown/absent -> safe (fail closed). */
|
|
14
|
+
export declare function writeGatedInMode(effect: CommitEffect, mode: string | undefined): boolean;
|
package/dist/publishGate.js
CHANGED
|
@@ -1,102 +1,154 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Control-effect classifier for the RUNNER's browser bridge.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// action unattended.
|
|
3
|
+
// PORTED from extension/src/protocol/publishGate.ts so the two gates agree
|
|
4
|
+
// verb-for-verb (FROZEN CONTRACT). The runner previously carried an OLDER
|
|
5
|
+
// 2-family list (publish/purchase, English only), so in Safe mode `create`,
|
|
6
|
+
// `delete`, `save`, `confirm`, `transfer`, and every non-English label ran
|
|
7
|
+
// UNATTENDED on the runner path — the exact gap the extension gate already
|
|
8
|
+
// closed. This brings the runner to parity: four effect families, eight
|
|
9
|
+
// locales.
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
11
|
+
// The runner reads a control's accessible NAME (readAccessibleNameAtPoint) and
|
|
12
|
+
// classifies it here; it has no structural (submitLike) signal, so the caller
|
|
13
|
+
// supplies a forced effect for actions that are commits by construction
|
|
14
|
+
// (browser_submit) and fails closed when the name could not be read at all.
|
|
15
|
+
export const PURCHASE_VERBS = [
|
|
16
|
+
// en
|
|
17
|
+
"buy", "buy now", "pay", "pay now", "place order", "order", "checkout",
|
|
18
|
+
"check out", "purchase", "subscribe", "donate", "confirm and pay",
|
|
19
|
+
"complete purchase", "place bid", "add to cart", "proceed to payment",
|
|
20
|
+
// es / pt
|
|
21
|
+
"comprar", "pagar", "pagar ahora", "finalizar compra", "realizar pedido",
|
|
22
|
+
"suscribirse", "donar", "confirmar y pagar", "finalizar pedido", "assinar",
|
|
23
|
+
// fr
|
|
24
|
+
"acheter", "payer", "commander", "passer commande", "s'abonner", "abonner",
|
|
25
|
+
"faire un don", "valider le paiement", "proceder au paiement",
|
|
26
|
+
// ru
|
|
27
|
+
"купить", "оплатить", "заказать", "подписаться", "оформить заказ",
|
|
28
|
+
// hi
|
|
29
|
+
"खरीदें", "भुगतान करें", "ऑर्डर करें", "सब्सक्राइब करें",
|
|
30
|
+
// tl
|
|
31
|
+
"bumili", "magbayad",
|
|
32
|
+
// zh
|
|
33
|
+
"购买", "付款", "立即购买", "结算", "提交订单", "订阅", "充值",
|
|
34
|
+
];
|
|
35
|
+
export const DESTRUCTIVE_VERBS = [
|
|
36
|
+
// en
|
|
37
|
+
"delete", "delete account", "remove", "erase", "wipe", "destroy", "discard",
|
|
38
|
+
"revoke", "disconnect", "unlink", "deactivate", "close account", "terminate",
|
|
39
|
+
"unsubscribe", "cancel subscription", "cancel plan", "archive", "trash",
|
|
40
|
+
"reset", "restore defaults", "leave", "block", "ban", "uninstall", "clear all",
|
|
41
|
+
// es / pt
|
|
42
|
+
"eliminar", "borrar", "quitar", "revocar", "desconectar", "desactivar",
|
|
43
|
+
"cancelar suscripcion", "archivar", "restablecer", "excluir", "remover",
|
|
44
|
+
"desativar", "cancelar assinatura", "arquivar", "redefinir",
|
|
45
|
+
// fr
|
|
46
|
+
"supprimer", "effacer", "retirer", "revoquer", "deconnecter", "desactiver",
|
|
47
|
+
"resilier", "archiver", "reinitialiser",
|
|
48
|
+
// ru
|
|
49
|
+
"удалить", "стереть", "отключить", "отозвать", "деактивировать", "отменить подписку", "сбросить",
|
|
50
|
+
// hi
|
|
51
|
+
"हटाएं", "डिलीट करें", "निकालें", "रद्द करें",
|
|
52
|
+
// tl
|
|
53
|
+
"burahin", "alisin", "tanggalin",
|
|
54
|
+
// zh
|
|
55
|
+
"删除", "移除", "清除", "销毁", "注销", "解绑", "取消订阅", "归档", "重置",
|
|
38
56
|
];
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
57
|
+
export const ACCOUNT_VERBS = [
|
|
58
|
+
// en
|
|
59
|
+
"change password", "update password", "reset password", "change email",
|
|
60
|
+
"update email", "enable two-factor", "disable two-factor", "add member",
|
|
61
|
+
"invite", "invite member", "transfer ownership", "make admin", "grant access",
|
|
62
|
+
"authorize", "authorise", "allow access", "connect account", "link account",
|
|
63
|
+
"add payment method", "withdraw", "transfer", "send money",
|
|
64
|
+
// es / pt
|
|
65
|
+
"cambiar contrasena", "invitar", "autorizar", "conceder acceso", "transferir",
|
|
66
|
+
"retirar", "alterar senha", "convidar", "autorizar acesso",
|
|
67
|
+
// fr
|
|
68
|
+
"changer le mot de passe", "inviter", "autoriser", "transferer", "retirer",
|
|
69
|
+
// ru
|
|
70
|
+
"сменить пароль", "пригласить", "разрешить", "перевести", "вывести",
|
|
71
|
+
// hi
|
|
72
|
+
"पासवर्ड बदलें", "आमंत्रित करें", "अनुमति दें",
|
|
73
|
+
// tl
|
|
74
|
+
"mag-imbita", "payagan",
|
|
75
|
+
// zh
|
|
76
|
+
"修改密码", "邀请", "授权", "允许", "转让", "提现", "转账",
|
|
77
|
+
];
|
|
78
|
+
export const PUBLISH_VERBS = [
|
|
79
|
+
// en — the original list, plus every write verb it was missing
|
|
80
|
+
"post", "comment", "reply", "send", "publish", "share", "tweet", "retweet",
|
|
81
|
+
"repost", "submit", "post comment", "add comment", "leave a comment",
|
|
82
|
+
"send message", "create", "save", "update", "apply", "confirm", "accept",
|
|
83
|
+
"agree", "add", "upload", "book", "reserve", "sign", "sign up", "register",
|
|
84
|
+
"follow", "like", "upvote", "download", "install", "deploy", "merge",
|
|
85
|
+
"approve", "continue", "finish", "done", "next",
|
|
86
|
+
// es / pt
|
|
87
|
+
"publicar", "comentar", "responder", "enviar", "compartir", "crear",
|
|
88
|
+
"guardar", "actualizar", "aplicar", "confirmar", "aceptar", "anadir",
|
|
89
|
+
"agregar", "seguir", "registrarse", "continuar", "criar", "salvar",
|
|
90
|
+
"atualizar", "aceitar", "adicionar", "seguir", "continuar", "compartilhar",
|
|
91
|
+
// fr
|
|
92
|
+
"publier", "commenter", "repondre", "envoyer", "partager", "creer",
|
|
93
|
+
"enregistrer", "sauvegarder", "mettre a jour", "appliquer", "confirmer",
|
|
94
|
+
"accepter", "ajouter", "suivre", "s'inscrire", "continuer", "valider",
|
|
95
|
+
// ru
|
|
96
|
+
"опубликовать", "комментировать", "ответить", "отправить", "поделиться",
|
|
97
|
+
"создать", "сохранить", "обновить", "применить", "подтвердить", "принять",
|
|
98
|
+
"добавить", "подписаться", "продолжить", "зарегистрироваться",
|
|
99
|
+
// hi
|
|
100
|
+
"पोस्ट करें", "टिप्पणी करें", "भेजें", "साझा करें", "बनाएं",
|
|
101
|
+
"सहेजें", "अपडेट करें", "पुष्टि करें", "स्वीकार करें", "जोड़ें", "जारी रखें",
|
|
102
|
+
// tl
|
|
103
|
+
"ipadala", "ibahagi", "gumawa", "i-save", "kumpirmahin", "tanggapin",
|
|
104
|
+
"magdagdag", "magpatuloy", "sundan",
|
|
105
|
+
// zh
|
|
106
|
+
"发布", "发送", "评论", "回复", "分享", "转发", "提交", "创建",
|
|
107
|
+
"保存", "更新", "确认", "接受", "同意", "添加", "上传", "关注",
|
|
108
|
+
"点赞", "继续", "注册", "完成",
|
|
54
109
|
];
|
|
55
|
-
/** Escape a phrase for use inside a RegExp source. */
|
|
56
110
|
function escapeRe(s) {
|
|
57
111
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
58
112
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* already lowercased + trimmed. A phrase with internal spaces matches the
|
|
62
|
-
* same run of whitespace-separated words. */
|
|
113
|
+
// Word-aware for Latin scripts; plain containment for CJK/Devanagari (no word
|
|
114
|
+
// delimiters), which is the correct behaviour for those verb lists.
|
|
63
115
|
function containsPhrase(name, phrase) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
return
|
|
116
|
+
const p = phrase.trim().toLowerCase();
|
|
117
|
+
if (!p)
|
|
118
|
+
return false;
|
|
119
|
+
const escaped = p.split(/\s+/).map(escapeRe).join("\\s+");
|
|
120
|
+
return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:$|[^a-z0-9])`, "i").test(name);
|
|
69
121
|
}
|
|
70
|
-
/** Classify a control
|
|
71
|
-
*
|
|
72
|
-
* then runs the action normally, no gate). The name is lowercased + trimmed
|
|
73
|
-
* here so callers can pass a raw accessible name. */
|
|
122
|
+
/** Classify a control name into a consequential effect, or null when benign.
|
|
123
|
+
* Precedence by blast radius: purchase, destructive, account_change, publish. */
|
|
74
124
|
export function classifyControlEffect(name) {
|
|
75
125
|
const n = String(name || "").toLowerCase().trim();
|
|
76
126
|
if (!n)
|
|
77
127
|
return null;
|
|
78
|
-
for (const
|
|
79
|
-
if (containsPhrase(n,
|
|
128
|
+
for (const p of PURCHASE_VERBS)
|
|
129
|
+
if (containsPhrase(n, p))
|
|
80
130
|
return "purchase";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
for (const p of DESTRUCTIVE_VERBS)
|
|
132
|
+
if (containsPhrase(n, p))
|
|
133
|
+
return "destructive";
|
|
134
|
+
for (const p of ACCOUNT_VERBS)
|
|
135
|
+
if (containsPhrase(n, p))
|
|
136
|
+
return "account_change";
|
|
137
|
+
for (const p of PUBLISH_VERBS)
|
|
138
|
+
if (containsPhrase(n, p))
|
|
84
139
|
return "publish";
|
|
85
|
-
}
|
|
86
140
|
return null;
|
|
87
141
|
}
|
|
88
|
-
/** Does
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* autonomous -> gate nothing (the user authorized end-to-end action)
|
|
94
|
-
* Unknown/absent fails closed to 'safe'. */
|
|
142
|
+
/** Does this effect need approval in the given autonomy mode?
|
|
143
|
+
* safe -> gate every consequential write
|
|
144
|
+
* payments_only -> gate purchases AND the two irreversible families
|
|
145
|
+
* autonomous -> gate nothing
|
|
146
|
+
* Unknown/absent -> safe (fail closed). */
|
|
95
147
|
export function writeGatedInMode(effect, mode) {
|
|
96
|
-
const m =
|
|
148
|
+
const m = (mode || "safe").toLowerCase();
|
|
97
149
|
if (m === "autonomous")
|
|
98
150
|
return false;
|
|
99
151
|
if (m === "payments_only")
|
|
100
|
-
return effect
|
|
152
|
+
return effect !== "publish";
|
|
101
153
|
return true;
|
|
102
154
|
}
|
package/dist/pythonEnv.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from "child_process";
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
14
14
|
import { createHash } from "crypto";
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir, platform } from "os";
|
|
@@ -19,6 +19,29 @@ const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
|
19
19
|
const VENV_DIR = join(CACHE_DIR, "venv");
|
|
20
20
|
const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
|
|
21
21
|
const AGENTSCOPE = join(CACHE_DIR, "agentscope");
|
|
22
|
+
// Supported CPython range for the venv — MUST match cli.ts's findPython(). A
|
|
23
|
+
// venv is a thin wrapper frozen to the interpreter that created it, so one
|
|
24
|
+
// built from an unsupported minor (e.g. a pre-fix runner that grabbed
|
|
25
|
+
// Homebrew's python3.13) can never install agentscope's deps and its import
|
|
26
|
+
// probe always fails. We detect that from pyvenv.cfg and rebuild automatically.
|
|
27
|
+
const PY_SUPPORTED_MIN = 10;
|
|
28
|
+
const PY_SUPPORTED_MAX = 12;
|
|
29
|
+
// The minor version a venv was built from, read from its pyvenv.cfg
|
|
30
|
+
// (`version = 3.12.4`). Returns null if the file is missing/unparseable.
|
|
31
|
+
function venvMinorVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const cfg = readFileSync(join(VENV_DIR, "pyvenv.cfg"), "utf-8");
|
|
34
|
+
const m = cfg.match(/version(?:_info)?\s*=\s*3\.(\d+)/i);
|
|
35
|
+
return m ? Number(m[1]) : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function venvVersionSupported() {
|
|
42
|
+
const minor = venvMinorVersion();
|
|
43
|
+
return minor !== null && minor >= PY_SUPPORTED_MIN && minor <= PY_SUPPORTED_MAX;
|
|
44
|
+
}
|
|
22
45
|
// Explicit dependency list. The shared-bundle endpoint ships only
|
|
23
46
|
// `*.py` files — no pyproject.toml — so `pip install -e
|
|
24
47
|
// ~/.melaya-runner/agentscope` does NOT work (pip can't find a project
|
|
@@ -243,6 +266,11 @@ function venvIsValid(expectedVersion) {
|
|
|
243
266
|
return false;
|
|
244
267
|
if (!existsSync(VENV_MARK))
|
|
245
268
|
return false;
|
|
269
|
+
// A venv built from an unsupported Python is never "valid", regardless of the
|
|
270
|
+
// marker — this forces the rebuild path (which recreates it from a supported
|
|
271
|
+
// interpreter) instead of self-healing a doomed venv.
|
|
272
|
+
if (!venvVersionSupported())
|
|
273
|
+
return false;
|
|
246
274
|
try {
|
|
247
275
|
const cached = readFileSync(VENV_MARK, "utf-8").trim();
|
|
248
276
|
return cached === venvMarkerValue(expectedVersion);
|
|
@@ -251,13 +279,43 @@ function venvIsValid(expectedVersion) {
|
|
|
251
279
|
return false;
|
|
252
280
|
}
|
|
253
281
|
}
|
|
254
|
-
function runProc(cmd, args, onLine, envExtra = {}
|
|
282
|
+
function runProc(cmd, args, onLine, envExtra = {},
|
|
283
|
+
// INACTIVITY clock, not a flat one. A first-run bootstrap (venv + full dep
|
|
284
|
+
// install + browser download) legitimately takes 10+ minutes, but it STREAMS
|
|
285
|
+
// progress the whole time — so we bound silence, not duration. A black-holed
|
|
286
|
+
// pip mirror or a stalled download produces no output; after this long with
|
|
287
|
+
// nothing, kill the child and fail non-zero rather than hang ensurePythonEnv
|
|
288
|
+
// (which every run, RAG ingest and assistant boot sits behind).
|
|
289
|
+
inactivityMs = 10 * 60_000) {
|
|
255
290
|
return new Promise((resolve) => {
|
|
256
291
|
const child = spawn(cmd, args, {
|
|
257
292
|
stdio: ["ignore", "pipe", "pipe"],
|
|
258
293
|
env: { ...process.env, ...envExtra },
|
|
259
294
|
});
|
|
295
|
+
let done = false;
|
|
296
|
+
let timer;
|
|
297
|
+
const finish = (code) => {
|
|
298
|
+
if (done)
|
|
299
|
+
return;
|
|
300
|
+
done = true;
|
|
301
|
+
if (timer)
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
resolve(code);
|
|
304
|
+
};
|
|
305
|
+
const bump = () => {
|
|
306
|
+
if (timer)
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
timer = setTimeout(() => {
|
|
309
|
+
try {
|
|
310
|
+
child.kill("SIGKILL");
|
|
311
|
+
}
|
|
312
|
+
catch { /* already gone */ }
|
|
313
|
+
onLine(`[runProc] no output for ${Math.round(inactivityMs / 60000)} min — killing '${cmd}'`);
|
|
314
|
+
finish(1);
|
|
315
|
+
}, inactivityMs);
|
|
316
|
+
};
|
|
260
317
|
const chew = (b) => {
|
|
318
|
+
bump();
|
|
261
319
|
for (const ln of b.toString().split("\n")) {
|
|
262
320
|
const t = ln.trimEnd();
|
|
263
321
|
if (t)
|
|
@@ -266,8 +324,9 @@ function runProc(cmd, args, onLine, envExtra = {}) {
|
|
|
266
324
|
};
|
|
267
325
|
child.stdout?.on("data", chew);
|
|
268
326
|
child.stderr?.on("data", chew);
|
|
269
|
-
child.on("exit", (code) =>
|
|
270
|
-
child.on("error", () =>
|
|
327
|
+
child.on("exit", (code) => finish(code ?? 1));
|
|
328
|
+
child.on("error", () => finish(1));
|
|
329
|
+
bump(); // start the clock
|
|
271
330
|
});
|
|
272
331
|
}
|
|
273
332
|
/**
|
|
@@ -345,6 +404,28 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
345
404
|
};
|
|
346
405
|
}
|
|
347
406
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
407
|
+
// Step 0: heal a venv left behind by a runner that built it from an
|
|
408
|
+
// unsupported interpreter (the classic case: a pre-fix runner grabbed
|
|
409
|
+
// Homebrew's python3.13, so agentscope's deps never installed and the import
|
|
410
|
+
// probe fails forever). A venv's Python is frozen at creation, so the only
|
|
411
|
+
// fix is to delete and recreate it from `systemPython` — which the CLI now
|
|
412
|
+
// guarantees is a supported 3.10–3.12. Fully automatic: no "delete the venv
|
|
413
|
+
// and restart" by hand.
|
|
414
|
+
if (existsSync(venvPython()) && !venvVersionSupported()) {
|
|
415
|
+
const minor = venvMinorVersion();
|
|
416
|
+
onProgress(`existing venv is Python ${minor === null ? "unknown" : `3.${minor}`} ` +
|
|
417
|
+
`(unsupported — need 3.${PY_SUPPORTED_MIN}–3.${PY_SUPPORTED_MAX}); rebuilding it`);
|
|
418
|
+
try {
|
|
419
|
+
rmSync(VENV_DIR, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
pythonPath: systemPython,
|
|
425
|
+
reason: `could not remove the stale venv at ${VENV_DIR} (${e?.message}). Delete it manually and restart the runner.`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
}
|
|
348
429
|
// Step 1: create the venv if missing.
|
|
349
430
|
if (!existsSync(venvPython())) {
|
|
350
431
|
onProgress(`creating venv at ${VENV_DIR} (one-time, ~5s)`);
|
|
@@ -408,19 +489,22 @@ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress
|
|
|
408
489
|
// identical whether the venv is fresh or already valid.
|
|
409
490
|
await ensureNltkData(onProgress);
|
|
410
491
|
await _resolveAndCacheCertBundle(onProgress);
|
|
411
|
-
writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
|
|
412
492
|
// sanity: confirm shortuuid + agentscope (via PYTHONPATH) resolve now.
|
|
413
493
|
// Probe must mirror the spawn env so PYTHONPATH=CACHE_DIR points at
|
|
414
494
|
// the cached agentscope source — without this the probe ImportError's
|
|
415
|
-
// even though the real spawn would work.
|
|
495
|
+
// even though the real spawn would work. The marker is written ONLY after
|
|
496
|
+
// this passes, so a half-built venv is never cached as valid: the next
|
|
497
|
+
// launch re-enters this rebuild path and self-heals instead of returning a
|
|
498
|
+
// broken venv as ok.
|
|
416
499
|
const probe = await runProc(venvPython(), ["-c", "import shortuuid, agentscope, anthropic, openai"], onProgress, { PYTHONPATH: CACHE_DIR });
|
|
417
500
|
if (probe !== 0) {
|
|
418
501
|
return {
|
|
419
502
|
ok: false,
|
|
420
503
|
pythonPath: systemPython,
|
|
421
|
-
reason: "venv
|
|
504
|
+
reason: `agentscope import probe failed on the ${venvMinorVersion() === null ? "" : `Python 3.${venvMinorVersion()} `}venv — the deps above did not import. See the pip lines above for the cause; the venv will be rebuilt on next launch.`,
|
|
422
505
|
};
|
|
423
506
|
}
|
|
507
|
+
writeFileSync(VENV_MARK, venvMarkerValue(expectedVersion), "utf-8");
|
|
424
508
|
onProgress(`✓ venv ready (python=${venvPython()})`);
|
|
425
509
|
return { ok: true, pythonPath: venvPython() };
|
|
426
510
|
}
|