@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.
- package/dist/assistantHost.py +26 -2
- package/dist/browserBridge.js +47 -5
- package/dist/connection.js +90 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/browserBridge.js
CHANGED
|
@@ -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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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.
|
package/dist/connection.js
CHANGED
|
@@ -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
|
});
|