@melaya/runner 1.1.6 → 1.1.7

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.
@@ -248,13 +248,29 @@ _HITL_MODES = ("safe", "autonomous", "payments_only")
248
248
  # PURGED at turn end (success, error, or cancel) so a reusable grant never
249
249
  # survives in the warm host environment; the server additionally revokes the
250
250
  # jti at turn end, and the grant's own exp bounds it.
251
- _BROWSER_GRANT_ENVS = ("MEL_BROWSER_TURN_GRANT", "MEL_BROWSER_TURN_TARGET_REF")
251
+ #
252
+ # MEL_BROWSER_URL / MEL_BROWSER_TOKEN are injected per-turn (not at spawn)
253
+ # for the assistant path: the runner registers a per-turn bridge run and
254
+ # delivers the url + bearer token on the turn frame alongside the grant. They
255
+ # are also purged at turn end so the bridge bearer token never leaks between
256
+ # turns (the bridge run is torn down by the runner's "done" watcher anyway,
257
+ # making the token stale, but belt-and-suspenders purge here for parity).
258
+ _BROWSER_GRANT_ENVS = (
259
+ "MEL_BROWSER_TURN_GRANT",
260
+ "MEL_BROWSER_TURN_TARGET_REF",
261
+ "MEL_BROWSER_URL",
262
+ "MEL_BROWSER_TOKEN",
263
+ )
252
264
 
253
265
 
254
266
  def _apply_browser_turn_grant(req) -> bool:
255
267
  """Install (or clear) the turn's browser grant env. Returns True when this
256
268
  turn carries a browser target. Absent/empty grant ⇒ env cleared (fail
257
- closed: the toolkit refuses to act without a live grant)."""
269
+ closed: the toolkit refuses to act without a live grant).
270
+
271
+ Also applies MEL_BROWSER_URL / MEL_BROWSER_TOKEN from the turn frame when
272
+ present (injected by the runner's per-turn bridge registration). These are
273
+ purged at turn end alongside the grant (see _purge_browser_turn_grant)."""
258
274
  grant = str((req or {}).get("browser_grant") or "") if isinstance(req, dict) else ""
259
275
  target_ref = str((req or {}).get("browser_target_ref") or "") if isinstance(req, dict) else ""
260
276
  if grant and _browser_capable():
@@ -263,6 +279,14 @@ def _apply_browser_turn_grant(req) -> bool:
263
279
  os.environ["MEL_BROWSER_TURN_TARGET_REF"] = target_ref
264
280
  else:
265
281
  os.environ.pop("MEL_BROWSER_TURN_TARGET_REF", None)
282
+ # Per-turn bridge URL + token (assistant path only; pipeline path has
283
+ # these in the spawn env already). Apply only when present on the frame.
284
+ bridge_url = str((req or {}).get("mel_browser_url") or "") if isinstance(req, dict) else ""
285
+ bridge_token = str((req or {}).get("mel_browser_token") or "") if isinstance(req, dict) else ""
286
+ if bridge_url:
287
+ os.environ["MEL_BROWSER_URL"] = bridge_url
288
+ if bridge_token:
289
+ os.environ["MEL_BROWSER_TOKEN"] = bridge_token
266
290
  return True
267
291
  _purge_browser_turn_grant()
268
292
  return False
@@ -139,6 +139,28 @@ export async function startBrowserBridge(opts) {
139
139
  },
140
140
  isCancelled: () => reg.cancelled,
141
141
  };
142
+ // INTERACTIVE ATTACH: if the grant carries a browserSession that matches
143
+ // a live interactive session, reuse its Playwright context instead of
144
+ // launching a new browser. The run is a NON-OWNER (externalAttach=true):
145
+ // teardownRun will release the registration and its lease but NEVER close
146
+ // the interactive context — the user's browser stays open.
147
+ const browserSessionId = String(reg.spec.grant.browserSession || "");
148
+ if (browserSessionId) {
149
+ const interactive = getInteractiveSession(browserSessionId);
150
+ if (interactive && interactive.context && interactive.state !== "closed" && interactive.state !== "crashed") {
151
+ reg.externalAttach = true;
152
+ const context = interactive.context;
153
+ const page = context.pages()[0] ?? (await context.newPage());
154
+ sessions.leaseTarget(interactive, reg.spec.grant.target.ref, page);
155
+ // Enforce policy on the leased page only (same as cdp-attach mode):
156
+ // we do not take over context-wide routing for the interactive session.
157
+ await enforceOnPage(page, reg.policy, hooks);
158
+ log(`browser session attached to interactive: run=${reg.spec.runId.slice(0, 10)} session=${browserSessionId.slice(0, 16)}`);
159
+ // Return the interactive record directly — ensureSession callers
160
+ // (getLease, captureSnapshot, etc.) work against it unchanged.
161
+ return interactive;
162
+ }
163
+ }
142
164
  if (reg.spec.mode === "attach") {
143
165
  const ws = String(reg.spec.cdpWsEndpoint || "");
144
166
  if (!/^wss?:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(ws)) {
@@ -1176,6 +1198,7 @@ export async function startBrowserBridge(opts) {
1176
1198
  violations: [],
1177
1199
  sessionInit: null,
1178
1200
  traces: [],
1201
+ externalAttach: false,
1179
1202
  };
1180
1203
  byToken.set(token, reg);
1181
1204
  byRunId.set(spec.runId, reg);
@@ -1195,11 +1218,30 @@ export async function startBrowserBridge(opts) {
1195
1218
  if (lease.runId === runId)
1196
1219
  stopWatchLease(sid);
1197
1220
  }
1198
- await sessions.teardownRun(runId, reason);
1199
- // The grant object becomes unreachable here (revocation-by-forget:
1200
- // the jti was already burned at verification, the claims held only
1201
- // in this registration are dropped, and the bearer token dies).
1202
- log(`browser run torn down: ${runId.slice(0, 10)} reason=${reason}`);
1221
+ if (reg.externalAttach) {
1222
+ // This run was attached to an existing interactive session (non-owner).
1223
+ // Release only the target lease the run placed on the interactive record;
1224
+ // do NOT close the interactive Playwright context — the user's browser
1225
+ // must remain open after the turn ends.
1226
+ const browserSessionId = String(reg.spec.grant.browserSession || "");
1227
+ if (browserSessionId) {
1228
+ const interactive = interactiveSessions.get(browserSessionId);
1229
+ if (interactive) {
1230
+ // Remove only the run's own target ref from the interactive record's
1231
+ // lease map; all other leases (e.g. "interactive" from the watch
1232
+ // producer) are preserved.
1233
+ interactive.targets.delete(reg.spec.grant.target.ref);
1234
+ }
1235
+ }
1236
+ log(`browser run detached (non-owner): ${runId.slice(0, 10)} reason=${reason}`);
1237
+ }
1238
+ else {
1239
+ await sessions.teardownRun(runId, reason);
1240
+ // The grant object becomes unreachable here (revocation-by-forget:
1241
+ // the jti was already burned at verification, the claims held only
1242
+ // in this registration are dropped, and the bearer token dies).
1243
+ log(`browser run torn down: ${runId.slice(0, 10)} reason=${reason}`);
1244
+ }
1203
1245
  };
1204
1246
  const teardownAll = async (reason) => {
1205
1247
  // Tear down grant-scoped run sessions.
@@ -1457,6 +1457,11 @@ export async function connect(opts) {
1457
1457
  // them for the duration of the turn and purge them afterward.
1458
1458
  let verifiedGrantToken = null;
1459
1459
  let browserTargetRef = null;
1460
+ // Per-turn bridge run registration (plan 0.4 assistant path).
1461
+ // Stable runId scoped to this turn; cleaned up at turn "done".
1462
+ let turnBridgeRunId = null;
1463
+ let turnBridgeUrl = null;
1464
+ let turnBridgeToken = null;
1460
1465
  if (payload.browserGrant) {
1461
1466
  try {
1462
1467
  const grant = await verifyBrowserGrant(payload.browserGrant, {
@@ -1475,17 +1480,91 @@ export async function connect(opts) {
1475
1480
  // it locally via MEL_BROWSER_TURN_GRANT at call time, fail closed).
1476
1481
  verifiedGrantToken = payload.browserGrant;
1477
1482
  browserTargetRef = String(payload.browserTargetRef || grant.target.ref || "");
1483
+ // Register a per-turn bridge run so MEL_BROWSER_URL / MEL_BROWSER_TOKEN
1484
+ // reach the host process env for this turn. The run attaches to the
1485
+ // interactive session (grant.browserSession) via ensureSession's
1486
+ // interactive-attach path; teardownRun at turn "done" releases the
1487
+ // lease without closing the interactive browser (externalAttach guard).
1488
+ const bridge = await _ensureBrowserBridge();
1489
+ if (bridge) {
1490
+ const turnRunId = `${payload.sessionId}:${payload.turnId}`;
1491
+ // Avoid duplicate registration if a prior turn's teardown raced.
1492
+ if (!bridge.hasRun(turnRunId)) {
1493
+ const spec = {
1494
+ runId: turnRunId,
1495
+ grant,
1496
+ // Interactive session attach: ensureSession will reuse the
1497
+ // interactive browser via grant.browserSession.
1498
+ mode: "launch",
1499
+ space: { kind: "ephemeral" },
1500
+ codeMode: false,
1501
+ headless: false,
1502
+ };
1503
+ const { token } = bridge.registerRun(spec);
1504
+ turnBridgeRunId = turnRunId;
1505
+ turnBridgeUrl = bridge.url;
1506
+ turnBridgeToken = token;
1507
+ }
1508
+ }
1478
1509
  if (opts.verbose) {
1479
1510
  console.log(chalk.gray(` [browser-bridge] turn grant ok (session=${payload.sessionId.slice(0, 10)} ceiling=${grant.effectCeiling})`));
1480
1511
  }
1481
1512
  }
1482
1513
  catch (e) {
1514
+ // Tear down the partial bridge registration if we registered before
1515
+ // the error (e.g. a bind-assertion failed after registerRun).
1516
+ if (turnBridgeRunId) {
1517
+ const bridge = await _ensureBrowserBridge().catch(() => null);
1518
+ if (bridge)
1519
+ await bridge.teardownRun(turnBridgeRunId, "turn_grant_rejected").catch(() => { });
1520
+ turnBridgeRunId = null;
1521
+ turnBridgeUrl = null;
1522
+ turnBridgeToken = null;
1523
+ }
1483
1524
  const code = e instanceof BrowserGrantError ? e.code : "grant_error";
1484
1525
  console.log(chalk.yellow(` [browser-bridge] turn grant rejected [${code}]: ${e?.message || e}`));
1485
1526
  socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `browser turn grant rejected (${code})` });
1486
1527
  return;
1487
1528
  }
1488
1529
  }
1530
+ // Capture for the stdout "done" handler closure below.
1531
+ const _turnBridgeRunId = turnBridgeRunId;
1532
+ const _turnId = payload.turnId;
1533
+ // When the host emits "done" for this turn, tear down the per-turn bridge
1534
+ // run. The teardown is additive-only to the pipeline RUN path and is safe
1535
+ // to call even if the run never had an active session (e.g. if the host
1536
+ // never called any browser tool this turn).
1537
+ if (_turnBridgeRunId) {
1538
+ // Attach a one-shot "done" watcher on the session's existing stdout
1539
+ // parser via a disposable event. We piggyback on the session's existing
1540
+ // event forwarding: s.proc already has a "data" listener; we add one
1541
+ // more small listener that self-removes after the first "done" for this
1542
+ // exact turnId.
1543
+ const _bridge = await _ensureBrowserBridge().catch(() => null);
1544
+ if (_bridge) {
1545
+ let _teardownBuf = "";
1546
+ const _onData = (data) => {
1547
+ _teardownBuf += data.toString();
1548
+ let idx;
1549
+ while ((idx = _teardownBuf.indexOf("\n")) >= 0) {
1550
+ const line = _teardownBuf.slice(0, idx);
1551
+ _teardownBuf = _teardownBuf.slice(idx + 1);
1552
+ const t = line.trim();
1553
+ if (!t.startsWith("MELASSIST "))
1554
+ continue;
1555
+ try {
1556
+ const parsed = JSON.parse(t.slice("MELASSIST ".length));
1557
+ if (parsed?.kind === "done" && parsed?.turnId === _turnId) {
1558
+ s.proc.stdout?.removeListener("data", _onData);
1559
+ void _bridge.teardownRun(_turnBridgeRunId, "turn_done");
1560
+ }
1561
+ }
1562
+ catch { /* skip malformed */ }
1563
+ }
1564
+ };
1565
+ s.proc.stdout?.on("data", _onData);
1566
+ }
1567
+ }
1489
1568
  try {
1490
1569
  s.proc.stdin?.write(JSON.stringify({
1491
1570
  turnId: payload.turnId,
@@ -1497,9 +1576,20 @@ export async function connect(opts) {
1497
1576
  // (defence-in-depth) and install MEL_BROWSER_TURN_GRANT in the
1498
1577
  // host env for THIS turn only. Absent when no grant was provided.
1499
1578
  ...(verifiedGrantToken ? { browser_grant: verifiedGrantToken, browser_target_ref: browserTargetRef ?? "" } : {}),
1579
+ // Bridge URL + token for the host process env (applied per-turn by
1580
+ // _apply_browser_turn_grant alongside the grant token).
1581
+ ...(turnBridgeUrl && turnBridgeToken
1582
+ ? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
1583
+ : {}),
1500
1584
  }) + "\n");
1501
1585
  }
1502
1586
  catch (e) {
1587
+ // Turn write failed — clean up the bridge registration immediately.
1588
+ if (_turnBridgeRunId) {
1589
+ const bridge = await _ensureBrowserBridge().catch(() => null);
1590
+ if (bridge)
1591
+ void bridge.teardownRun(_turnBridgeRunId, "turn_write_failed");
1592
+ }
1503
1593
  socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
1504
1594
  }
1505
1595
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,