@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.
@@ -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)
@@ -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 treats it as
1092
- * "no match" and runs the action normally. */
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
- return "";
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
- const escalated = classifyControlEffect(name);
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; // not a publish/purchase control: run normally.
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
- if (!writeGatedInMode(escalated, mode))
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
- await enforcePublishGate(reg, page, kind, "", ref || "focused form", args.hitl_mode);
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
- // THE LONG PRESS. Button down, pointer still, nothing dispatched:
1645
- // this interval is what the page uses to decide the item is picked
1646
- // up, and it is the whole difference from a plain drag.
1647
- await page.waitForTimeout(holdMs);
1648
- const path = buildDragPath(from, to, Math.round(moveMs / 25), moveMs, shape);
1649
- for (const p of path) {
1650
- await page.mouse.move(p.x, p.y);
1651
- await page.waitForTimeout(p.dwellMs);
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 python = await findPython();
57
- if (!python) {
58
- console.log(chalk.red(" ✗ Python not found in PATH"));
59
- console.log(chalk.gray(" Install Python 3.11+ from https://python.org"));
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
- console.log(chalk.green(` ✓ Python: ${python}`));
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
- async function findPython() {
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
- for (const cmd of ["python3", "python"]) {
87
- try {
88
- const version = execSync(`${cmd} --version`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
89
- if (version.includes("Python 3."))
90
- return cmd;
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
- catch { /* not found */ }
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 null;
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.
@@ -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
- console.log(chalk.red(` ✗ Run failed: ${e.message}`));
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
- if (activeAssistants.get(sid) === session)
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 };
@@ -1,13 +1,14 @@
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;
6
- /** Does a detected consequential effect need approval in the given AUTONOMY
7
- * mode? The single gate rule, driven by the turn's mode (NOT the grant ceiling,
8
- * which is always 'destructive'):
9
- * safe -> gate every consequential write (publish AND purchase)
10
- * payments_only -> gate only purchases
11
- * autonomous -> gate nothing (the user authorized end-to-end action)
12
- * Unknown/absent fails closed to 'safe'. */
13
- export declare function writeGatedInMode(effect: "publish" | "purchase", mode: string | undefined): boolean;
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;
@@ -1,102 +1,154 @@
1
- // packages/runner/src/publishGate.ts
1
+ // Control-effect classifier for the RUNNER's browser bridge.
2
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.
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
- // 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",
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 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",
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
- /** 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. */
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
- // \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);
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'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. */
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 phrase of PURCHASE_PHRASES) {
79
- if (containsPhrase(n, phrase))
128
+ for (const p of PURCHASE_VERBS)
129
+ if (containsPhrase(n, p))
80
130
  return "purchase";
81
- }
82
- for (const phrase of PUBLISH_PHRASES) {
83
- if (containsPhrase(n, phrase))
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 a detected consequential effect need approval in the given AUTONOMY
89
- * mode? The single gate rule, driven by the turn's mode (NOT the grant ceiling,
90
- * which is always 'destructive'):
91
- * safe -> gate every consequential write (publish AND purchase)
92
- * payments_only -> gate only purchases
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 = String(mode || "safe").toLowerCase();
148
+ const m = (mode || "safe").toLowerCase();
97
149
  if (m === "autonomous")
98
150
  return false;
99
151
  if (m === "payments_only")
100
- return effect === "purchase";
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) => resolve(code ?? 1));
270
- child.on("error", () => resolve(1));
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 created but agentscope import probe failed. Delete ~/.melaya-runner/venv and restart the runner.",
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.29",
3
+ "version": "1.1.31",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,