@melaya/runner 1.0.118 → 1.1.0
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 +144 -8
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +50 -0
- package/dist/browserBridge.js +1021 -0
- package/dist/browserGrantVerify.d.ts +91 -0
- package/dist/browserGrantVerify.js +353 -0
- package/dist/browserProvisioner.d.ts +33 -0
- package/dist/browserProvisioner.js +266 -0
- package/dist/codeWorker.d.ts +49 -0
- package/dist/codeWorker.js +538 -0
- package/dist/connection.js +270 -3
- package/dist/sessionManager.d.ts +108 -0
- package/dist/sessionManager.js +290 -0
- package/package.json +4 -2
package/dist/connection.js
CHANGED
|
@@ -26,8 +26,21 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
28
28
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
29
|
+
// Melaya Browser (plan Sections 3/4/7): the governed browser control
|
|
30
|
+
// bridge + its runner-side grant verifier and engine discovery.
|
|
31
|
+
import { startBrowserBridge } from "./browserBridge.js";
|
|
32
|
+
import { verifyBrowserGrant, createInMemoryReplayStore, loadPinnedPublicKeysFromEnv, BrowserGrantError, } from "./browserGrantVerify.js";
|
|
33
|
+
import { discoverEngines } from "./browserProvisioner.js";
|
|
34
|
+
import { getRunnerDeviceId } from "./sessionManager.js";
|
|
29
35
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
30
36
|
const activeProcesses = new Map();
|
|
37
|
+
// Melaya Browser: the versioned capability advertised in the runner hello
|
|
38
|
+
// (plan Section 4). Bumped when the browser wire contract changes.
|
|
39
|
+
const BROWSER_CONTROL_VERSION = 1;
|
|
40
|
+
// Reserved subprocess env names for the browser bridge. These are injected
|
|
41
|
+
// LAST and must NEVER be overwritten by payload.credentials (plan Section 4
|
|
42
|
+
// protected-env rule). Scrubbed from the inherited env as defense in depth.
|
|
43
|
+
const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
|
|
31
44
|
// PR4/P1-5: the assistant-session wire protocol version. Bumped whenever the
|
|
32
45
|
// handshake contract changes (this rev adds hostInstanceId + configHash + a
|
|
33
46
|
// versioned ready/hello). The server enforces a MINIMUM: a runner advertising a
|
|
@@ -65,6 +78,35 @@ export async function connect(opts) {
|
|
|
65
78
|
reconnectionAttempts: Infinity,
|
|
66
79
|
});
|
|
67
80
|
let relay = null;
|
|
81
|
+
// ── Melaya Browser bridge state (plan Sections 3/4) ──────────────────
|
|
82
|
+
// The governed browser control bridge is started lazily on the first
|
|
83
|
+
// browser run. Grant public keys arrive over the socket (server push)
|
|
84
|
+
// and/or are pinned via env (operator-hardened). The replay store keeps
|
|
85
|
+
// single-use jti enforcement local to this runner process.
|
|
86
|
+
let browserBridge = null;
|
|
87
|
+
const browserReplayStore = createInMemoryReplayStore();
|
|
88
|
+
const browserPublicKeys = loadPinnedPublicKeysFromEnv();
|
|
89
|
+
// socketGeneration: incremented on every (re)connect. A grant is minted
|
|
90
|
+
// for a specific generation; a stale grant (issued to a previous socket)
|
|
91
|
+
// is refused (plan Section 4 binding rule).
|
|
92
|
+
let socketGeneration = 0;
|
|
93
|
+
const runnerDeviceId = getRunnerDeviceId();
|
|
94
|
+
const _ensureBrowserBridge = async () => {
|
|
95
|
+
if (browserBridge)
|
|
96
|
+
return browserBridge;
|
|
97
|
+
try {
|
|
98
|
+
browserBridge = await startBrowserBridge({
|
|
99
|
+
log: (m) => { if (opts.verbose)
|
|
100
|
+
console.log(chalk.gray(` [browser-bridge] ${m}`)); },
|
|
101
|
+
verbose: opts.verbose,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
console.log(chalk.yellow(` ⚠ Browser bridge failed to start: ${e?.message || e}`));
|
|
106
|
+
browserBridge = null;
|
|
107
|
+
}
|
|
108
|
+
return browserBridge;
|
|
109
|
+
};
|
|
68
110
|
// Luma browser bridge — only started after a successful Luma sign-in
|
|
69
111
|
// (storage state file exists). Reset to a fresh bridge after each
|
|
70
112
|
// luma:login-result so cookie rotation is picked up immediately.
|
|
@@ -141,12 +183,39 @@ export async function connect(opts) {
|
|
|
141
183
|
};
|
|
142
184
|
socket.on("connect", async () => {
|
|
143
185
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
186
|
+
// New socket lifetime → new generation. Grants minted for an earlier
|
|
187
|
+
// generation are refused (plan Section 4). Any browser runs from the
|
|
188
|
+
// previous connection are already torn down on the disconnect path.
|
|
189
|
+
socketGeneration += 1;
|
|
144
190
|
// Report detected models
|
|
145
191
|
socket.emit("runner:models", opts.models);
|
|
192
|
+
// Discover installed browser engines (Chrome/Edge/Brave/bundled
|
|
193
|
+
// Chromium) so the hello can advertise the browser_control capability
|
|
194
|
+
// with an accurate engine + CDP report (plan Section 4). Best-effort:
|
|
195
|
+
// discovery failure still advertises the base capability so attach
|
|
196
|
+
// (which needs no local engine) stays available.
|
|
197
|
+
let browserEngines = [];
|
|
198
|
+
try {
|
|
199
|
+
browserEngines = (await discoverEngines()).map((e) => ({ engine: e.engine, version: e.version, hasChannel: e.channel !== null }));
|
|
200
|
+
}
|
|
201
|
+
catch { /* non-fatal */ }
|
|
146
202
|
// Advertise capabilities so the server only routes assistant chat sessions
|
|
147
203
|
// to runners new enough to host them (older runners never get one → the
|
|
148
204
|
// server falls back with a clear "update your runner" instead of hanging).
|
|
149
|
-
socket.emit("runner:hello", {
|
|
205
|
+
socket.emit("runner:hello", {
|
|
206
|
+
capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`],
|
|
207
|
+
package: "@melaya/runner",
|
|
208
|
+
version: RUNNER_VERSION,
|
|
209
|
+
assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
|
|
210
|
+
// Browser control capability report (plan Section 4).
|
|
211
|
+
browserControl: {
|
|
212
|
+
version: BROWSER_CONTROL_VERSION,
|
|
213
|
+
runnerDevice: runnerDeviceId,
|
|
214
|
+
socketGeneration,
|
|
215
|
+
cdp: true,
|
|
216
|
+
engines: browserEngines,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
150
219
|
// Start local event relay
|
|
151
220
|
if (!relay) {
|
|
152
221
|
relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
|
|
@@ -175,11 +244,57 @@ export async function connect(opts) {
|
|
|
175
244
|
});
|
|
176
245
|
socket.on("disconnect", (reason) => {
|
|
177
246
|
console.log(chalk.yellow(`\n Disconnected: ${reason}`));
|
|
247
|
+
// Melaya Browser: a dropped socket invalidates every browser grant
|
|
248
|
+
// bound to this socket generation. Tear down ALL browser runs
|
|
249
|
+
// (cancels in-flight ops, releases leases, closes owned browsers);
|
|
250
|
+
// this is a terminal path per plan Section 7.
|
|
251
|
+
if (browserBridge) {
|
|
252
|
+
browserBridge.teardownAll(`socket_disconnect:${reason}`).catch(() => { });
|
|
253
|
+
}
|
|
178
254
|
if (reason === "io server disconnect") {
|
|
179
255
|
console.log(chalk.red(" Server closed the connection. Check your token."));
|
|
180
256
|
process.exit(1);
|
|
181
257
|
}
|
|
182
258
|
});
|
|
259
|
+
// ── Melaya Browser: grant public key delivery (plan Section 0.6) ──────
|
|
260
|
+
// The server pushes its current EdDSA grant-verification public keys
|
|
261
|
+
// (kid → base64) so the runner can verify browserGrants. Merged with
|
|
262
|
+
// any env-pinned keys (env pins win — an operator can lock keys so a
|
|
263
|
+
// compromised socket cannot swap them). kid rotation is just a new
|
|
264
|
+
// entry in this map.
|
|
265
|
+
socket.on("runner:browserGrantKeys", (payload) => {
|
|
266
|
+
const keys = payload?.keys;
|
|
267
|
+
if (!keys || typeof keys !== "object")
|
|
268
|
+
return;
|
|
269
|
+
const pinned = loadPinnedPublicKeysFromEnv();
|
|
270
|
+
for (const [kid, b64] of Object.entries(keys)) {
|
|
271
|
+
if (typeof b64 !== "string" || !b64)
|
|
272
|
+
continue;
|
|
273
|
+
if (pinned[kid] !== undefined)
|
|
274
|
+
continue; // never override an env pin
|
|
275
|
+
browserPublicKeys[kid] = b64;
|
|
276
|
+
}
|
|
277
|
+
if (opts.verbose)
|
|
278
|
+
console.log(chalk.gray(` [browser-bridge] grant keys updated (${Object.keys(browserPublicKeys).length} kid(s))`));
|
|
279
|
+
});
|
|
280
|
+
// ── Melaya Browser: live-mirror watch-lease (plan Section 10.f) ──────
|
|
281
|
+
// The server emits `browser:watch { sessionId, active }` when a viewer
|
|
282
|
+
// starts or stops watching a browser session's mirror. The runner wires
|
|
283
|
+
// the watch-lease to the bridge which owns the Playwright session.
|
|
284
|
+
// Frames are POSTed to the server's frame endpoint authenticated by
|
|
285
|
+
// the runner token (Bearer).
|
|
286
|
+
socket.on("browser:watch", (payload) => {
|
|
287
|
+
const sessionId = String(payload?.sessionId || "");
|
|
288
|
+
const active = Boolean(payload?.active);
|
|
289
|
+
if (!sessionId || !browserBridge)
|
|
290
|
+
return;
|
|
291
|
+
const framePostUrl = opts.serverUrl.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://")) + "/api/v1/browser/frame";
|
|
292
|
+
const runAuthHeader = `Bearer ${opts.token}`;
|
|
293
|
+
browserBridge.setWatchLease(sessionId, active, framePostUrl, runAuthHeader);
|
|
294
|
+
if (opts.verbose) {
|
|
295
|
+
console.log(chalk.gray(` [browser-bridge] watch lease sessionId=${sessionId.slice(0, 16)} active=${active}`));
|
|
296
|
+
}
|
|
297
|
+
});
|
|
183
298
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
184
299
|
setInterval(() => {
|
|
185
300
|
if (socket.connected)
|
|
@@ -195,6 +310,71 @@ export async function connect(opts) {
|
|
|
195
310
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
196
311
|
return;
|
|
197
312
|
}
|
|
313
|
+
// ── Melaya Browser grant verification + bridge start ──────────────
|
|
314
|
+
// (plan Section 4) A browser run carries a signed browserGrant. We
|
|
315
|
+
// VERIFY it BEFORE any Python spawns and reject on ANY failure. The
|
|
316
|
+
// raw grant stays in runner memory (used to register the bridge run);
|
|
317
|
+
// only the localhost URL + a per-run bearer token reach the child.
|
|
318
|
+
let browserToken = null;
|
|
319
|
+
if (payload.browserGrant) {
|
|
320
|
+
try {
|
|
321
|
+
const grant = await verifyBrowserGrant(payload.browserGrant, {
|
|
322
|
+
expectedAud: runnerDeviceId,
|
|
323
|
+
publicKeysByKid: browserPublicKeys,
|
|
324
|
+
replayStore: browserReplayStore,
|
|
325
|
+
});
|
|
326
|
+
// Bind assertions (plan Section 4): the grant must target THIS
|
|
327
|
+
// runner device and THIS socket generation, and its run id must
|
|
328
|
+
// match the dispatch. Fail closed on any mismatch.
|
|
329
|
+
if (grant.runnerDevice !== runnerDeviceId) {
|
|
330
|
+
throw new BrowserGrantError("claims_invalid", `grant runnerDevice '${grant.runnerDevice}' != this device`);
|
|
331
|
+
}
|
|
332
|
+
if (grant.socketGeneration !== socketGeneration) {
|
|
333
|
+
throw new BrowserGrantError("claims_invalid", `grant socketGeneration ${grant.socketGeneration} != current ${socketGeneration}`);
|
|
334
|
+
}
|
|
335
|
+
if (grant.run !== payload.runId) {
|
|
336
|
+
throw new BrowserGrantError("claims_invalid", `grant run '${grant.run}' != dispatched run '${payload.runId}'`);
|
|
337
|
+
}
|
|
338
|
+
const bridge = await _ensureBrowserBridge();
|
|
339
|
+
if (!bridge)
|
|
340
|
+
throw new BrowserGrantError("claims_invalid", "browser bridge unavailable");
|
|
341
|
+
const spec = {
|
|
342
|
+
runId: payload.runId,
|
|
343
|
+
grant,
|
|
344
|
+
mode: payload.browser?.mode === "attach" ? "attach" : "launch",
|
|
345
|
+
engine: payload.browser?.engine,
|
|
346
|
+
cdpWsEndpoint: payload.browser?.cdpWsEndpoint,
|
|
347
|
+
space: payload.browser?.space ?? { kind: "ephemeral" },
|
|
348
|
+
codeMode: payload.browser?.codeMode === true,
|
|
349
|
+
headless: payload.browser?.headless === true,
|
|
350
|
+
};
|
|
351
|
+
const { token } = bridge.registerRun(spec);
|
|
352
|
+
browserToken = token;
|
|
353
|
+
console.log(chalk.hex("#7C6FF0")(` 🌐 Browser grant verified (mode=${spec.mode}, engine=${spec.engine || "chrome"}, ceiling=${grant.effectCeiling})`));
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
const code = e instanceof BrowserGrantError ? e.code : "grant_error";
|
|
357
|
+
console.log(chalk.red(` ✗ Browser grant rejected [${code}]: ${e?.message || e}`));
|
|
358
|
+
socket.emit("runner:event", {
|
|
359
|
+
run_id: payload.runId,
|
|
360
|
+
event_type: "agent_message",
|
|
361
|
+
project: payload.project,
|
|
362
|
+
replyId: `browser-grant-${payload.runId}`,
|
|
363
|
+
replyName: "Runner",
|
|
364
|
+
replyRole: "system",
|
|
365
|
+
msg: {
|
|
366
|
+
id: `browser-grant-${payload.runId}`,
|
|
367
|
+
name: "Runner",
|
|
368
|
+
role: "system",
|
|
369
|
+
content: [{ type: "text", text: `Browser grant rejected (${code}). This run cannot control a browser.` }],
|
|
370
|
+
metadata: { browserGrantError: code },
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
198
378
|
// Ensure shared modules are cached
|
|
199
379
|
await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
|
|
200
380
|
// Ensure the dedicated venv at ~/.melaya-runner/venv/ has
|
|
@@ -377,6 +557,11 @@ export async function connect(opts) {
|
|
|
377
557
|
if (k.startsWith("MEL_MODEL_"))
|
|
378
558
|
delete inherited[k];
|
|
379
559
|
}
|
|
560
|
+
// Reserved browser bridge env names are runner-owned and injected
|
|
561
|
+
// LAST below (plan Section 4). Scrub any operator-exported value so
|
|
562
|
+
// a stale/hostile MEL_BROWSER_* in the shell can't shadow ours.
|
|
563
|
+
for (const k of RESERVED_BROWSER_ENV)
|
|
564
|
+
delete inherited[k];
|
|
380
565
|
// certifi CA-bundle path resolved at venv setup. Set as both
|
|
381
566
|
// SSL_CERT_FILE (urllib / openssl) and REQUESTS_CA_BUNDLE (requests
|
|
382
567
|
// library) so EVERY HTTPS-using tool — gmail_send, slack_post_text,
|
|
@@ -462,6 +647,14 @@ export async function connect(opts) {
|
|
|
462
647
|
MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
|
|
463
648
|
}
|
|
464
649
|
: {}),
|
|
650
|
+
// ── Melaya Browser bridge vars, injected LAST (plan Section 4) ──
|
|
651
|
+
// These reserved names come AFTER payload.credentials so a
|
|
652
|
+
// credential can never overwrite them. Present only when this run
|
|
653
|
+
// passed browser-grant verification above; the raw grant is never
|
|
654
|
+
// placed in the child env (it stays in the bridge registration).
|
|
655
|
+
...(browserToken && browserBridge
|
|
656
|
+
? { MEL_BROWSER_URL: browserBridge.url, MEL_BROWSER_TOKEN: browserToken }
|
|
657
|
+
: {}),
|
|
465
658
|
};
|
|
466
659
|
// Surface the tier to the chat panel as a system message so the
|
|
467
660
|
// operator sees WHY a small model is running with tighter caps.
|
|
@@ -518,6 +711,12 @@ export async function connect(opts) {
|
|
|
518
711
|
});
|
|
519
712
|
proc.on("exit", (code) => {
|
|
520
713
|
activeProcesses.delete(payload.runId);
|
|
714
|
+
// Melaya Browser terminal path (plan Section 7): the pipeline
|
|
715
|
+
// process exited, so revoke the grant, cancel in-flight browser
|
|
716
|
+
// ops, release leases, and close ONLY owned browsers. Idempotent.
|
|
717
|
+
if (browserBridge && browserBridge.hasRun(payload.runId)) {
|
|
718
|
+
browserBridge.teardownRun(payload.runId, `run_exit:${code}`).catch(() => { });
|
|
719
|
+
}
|
|
521
720
|
const status = code === 0 ? "done" : "failed";
|
|
522
721
|
// On non-zero exit, relay the captured stderr tail to the FE
|
|
523
722
|
// as a system agent_message so the operator sees the actual
|
|
@@ -987,6 +1186,14 @@ export async function connect(opts) {
|
|
|
987
1186
|
// (desktop drives the paired phone remotely). The host ORs this with the
|
|
988
1187
|
// mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
|
|
989
1188
|
MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
|
|
1189
|
+
// Melaya Browser (plan 0.4): the CAPABILITY flag. Presence means the
|
|
1190
|
+
// runner advertises browser control and the browser toolkit should be
|
|
1191
|
+
// included in the host's toolkit. The TARGET GRANT is NEVER in the boot
|
|
1192
|
+
// env; it arrives per-turn on the turn frame and is purged at turn end.
|
|
1193
|
+
// This is part of the config hash so the host reboots if capability
|
|
1194
|
+
// presence changes (a runner that gains/loses browser control restarts
|
|
1195
|
+
// the host automatically via config-drift detection above).
|
|
1196
|
+
MEL_ASSISTANT_BROWSER_CAPABLE: "1",
|
|
990
1197
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
991
1198
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
992
1199
|
// are reachable without exploding context.
|
|
@@ -1149,7 +1356,7 @@ export async function connect(opts) {
|
|
|
1149
1356
|
}
|
|
1150
1357
|
catch { /* best-effort — host stays as-is on a write failure */ }
|
|
1151
1358
|
});
|
|
1152
|
-
socket.on("runner:assistant_turn", (payload) => {
|
|
1359
|
+
socket.on("runner:assistant_turn", async (payload) => {
|
|
1153
1360
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
1154
1361
|
if (!s) {
|
|
1155
1362
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
|
|
@@ -1171,8 +1378,56 @@ export async function connect(opts) {
|
|
|
1171
1378
|
const hitlMode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
|
|
1172
1379
|
// static_context (persona / standing instructions) is forwarded per turn; the
|
|
1173
1380
|
// host folds it into its system prompt before running (parity with cloud).
|
|
1381
|
+
//
|
|
1382
|
+
// Melaya Browser per-turn grant (plan 0.4): verify the grant ON THIS
|
|
1383
|
+
// RUNNER before forwarding to the host. On failure, send an error event
|
|
1384
|
+
// and abort the turn — never inject an unverified grant token into the
|
|
1385
|
+
// host environment. On success, forward the raw compact JWS token plus
|
|
1386
|
+
// the target ref so the host's _apply_browser_turn_grant can install
|
|
1387
|
+
// them for the duration of the turn and purge them afterward.
|
|
1388
|
+
let verifiedGrantToken = null;
|
|
1389
|
+
let browserTargetRef = null;
|
|
1390
|
+
if (payload.browserGrant) {
|
|
1391
|
+
try {
|
|
1392
|
+
const grant = await verifyBrowserGrant(payload.browserGrant, {
|
|
1393
|
+
expectedAud: runnerDeviceId,
|
|
1394
|
+
publicKeysByKid: browserPublicKeys,
|
|
1395
|
+
replayStore: browserReplayStore,
|
|
1396
|
+
});
|
|
1397
|
+
// Bind assertions: must target this device and socket generation.
|
|
1398
|
+
if (grant.runnerDevice !== runnerDeviceId) {
|
|
1399
|
+
throw new BrowserGrantError("claims_invalid", `grant runnerDevice mismatch: ${grant.runnerDevice}`);
|
|
1400
|
+
}
|
|
1401
|
+
if (grant.socketGeneration !== socketGeneration) {
|
|
1402
|
+
throw new BrowserGrantError("claims_invalid", `grant socketGeneration ${grant.socketGeneration} != current ${socketGeneration}`);
|
|
1403
|
+
}
|
|
1404
|
+
// Accept the compact JWS token (the host's browser toolkit verifies
|
|
1405
|
+
// it locally via MEL_BROWSER_TURN_GRANT at call time, fail closed).
|
|
1406
|
+
verifiedGrantToken = payload.browserGrant;
|
|
1407
|
+
browserTargetRef = String(payload.browserTargetRef || grant.target.ref || "");
|
|
1408
|
+
if (opts.verbose) {
|
|
1409
|
+
console.log(chalk.gray(` [browser-bridge] turn grant ok (session=${payload.sessionId.slice(0, 10)} ceiling=${grant.effectCeiling})`));
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
catch (e) {
|
|
1413
|
+
const code = e instanceof BrowserGrantError ? e.code : "grant_error";
|
|
1414
|
+
console.log(chalk.yellow(` [browser-bridge] turn grant rejected [${code}]: ${e?.message || e}`));
|
|
1415
|
+
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `browser turn grant rejected (${code})` });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1174
1419
|
try {
|
|
1175
|
-
s.proc.stdin?.write(JSON.stringify({
|
|
1420
|
+
s.proc.stdin?.write(JSON.stringify({
|
|
1421
|
+
turnId: payload.turnId,
|
|
1422
|
+
message: payload.message,
|
|
1423
|
+
hitl_mode: hitlMode,
|
|
1424
|
+
static_context: typeof payload.staticContext === "string" ? payload.staticContext : "",
|
|
1425
|
+
// Per-turn browser grant: forwarded to the host as the raw compact
|
|
1426
|
+
// JWS string so the Python browser toolkit can verify it again
|
|
1427
|
+
// (defence-in-depth) and install MEL_BROWSER_TURN_GRANT in the
|
|
1428
|
+
// host env for THIS turn only. Absent when no grant was provided.
|
|
1429
|
+
...(verifiedGrantToken ? { browser_grant: verifiedGrantToken, browser_target_ref: browserTargetRef ?? "" } : {}),
|
|
1430
|
+
}) + "\n");
|
|
1176
1431
|
}
|
|
1177
1432
|
catch (e) {
|
|
1178
1433
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
@@ -1727,6 +1982,12 @@ export async function connect(opts) {
|
|
|
1727
1982
|
activeProcesses.delete(data.runId);
|
|
1728
1983
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
1729
1984
|
}
|
|
1985
|
+
// Melaya Browser terminal path (plan Section 7): an explicit kill
|
|
1986
|
+
// revokes the grant and closes owned browsers even if the Python proc
|
|
1987
|
+
// was already gone. Idempotent.
|
|
1988
|
+
if (browserBridge && browserBridge.hasRun(data.runId)) {
|
|
1989
|
+
browserBridge.teardownRun(data.runId, "runner_kill").catch(() => { });
|
|
1990
|
+
}
|
|
1730
1991
|
});
|
|
1731
1992
|
// ── Pause / resume crew strategy (local-runner only) ───────────────
|
|
1732
1993
|
// For local-runner crew strategies, the Redis-flag pause path used by
|
|
@@ -1780,6 +2041,12 @@ export async function connect(opts) {
|
|
|
1780
2041
|
catch { /* noop */ }
|
|
1781
2042
|
}
|
|
1782
2043
|
activeAssistants.clear();
|
|
2044
|
+
// Melaya Browser terminal path (plan Section 7): shut the bridge down,
|
|
2045
|
+
// which cancels in-flight ops, releases every lease, and closes ONLY
|
|
2046
|
+
// owned browsers (attached user browsers are merely disconnected).
|
|
2047
|
+
if (browserBridge) {
|
|
2048
|
+
browserBridge.shutdown().catch(() => { });
|
|
2049
|
+
}
|
|
1783
2050
|
relay?.close();
|
|
1784
2051
|
// Best-effort Chromium teardown. The bridge's shutdown is async but
|
|
1785
2052
|
// we can't await inside a SIGINT handler — fire and forget; the
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type PWBrowser = import("playwright").Browser;
|
|
2
|
+
type PWBrowserContext = import("playwright").BrowserContext;
|
|
3
|
+
type PWPage = import("playwright").Page;
|
|
4
|
+
export declare function getRunnerDeviceId(): string;
|
|
5
|
+
export type SessionOwnership = "owned" | "attached";
|
|
6
|
+
export type SessionState = "connecting" | "ready" | "running" | "paused" | "crashed" | "closed";
|
|
7
|
+
export type SpaceKind = "ephemeral" | "persistent";
|
|
8
|
+
export interface SpaceSpec {
|
|
9
|
+
kind: SpaceKind;
|
|
10
|
+
/** Stable opaque id, required for persistent Spaces. */
|
|
11
|
+
id?: string;
|
|
12
|
+
}
|
|
13
|
+
/** A single @eN binding. Refs are valid only while EVERY generation
|
|
14
|
+
* component still matches the live lease (plan Section 8). */
|
|
15
|
+
export interface BoundRef {
|
|
16
|
+
ref: string;
|
|
17
|
+
targetRef: string;
|
|
18
|
+
frameKey: string;
|
|
19
|
+
backendNodeId: number;
|
|
20
|
+
snapshotGeneration: number;
|
|
21
|
+
documentGeneration: number;
|
|
22
|
+
role: string;
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TargetLease {
|
|
26
|
+
/** Opaque target ref (matches grant.target.ref for the leased target). */
|
|
27
|
+
ref: string;
|
|
28
|
+
page: PWPage;
|
|
29
|
+
snapshotGeneration: number;
|
|
30
|
+
documentGeneration: number;
|
|
31
|
+
refs: Map<string, BoundRef>;
|
|
32
|
+
lastActivity: number;
|
|
33
|
+
/** Serialize writes per target (plan Section 7 concurrency rule). */
|
|
34
|
+
opChain: Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface BrowserSessionRecord {
|
|
37
|
+
id: string;
|
|
38
|
+
runId: string;
|
|
39
|
+
ownership: SessionOwnership;
|
|
40
|
+
engine: string;
|
|
41
|
+
state: SessionState;
|
|
42
|
+
space: SpaceSpec;
|
|
43
|
+
browser: PWBrowser | null;
|
|
44
|
+
/** launchPersistentContext returns a context without a Browser handle;
|
|
45
|
+
* owned sessions therefore key teardown on context, not browser. */
|
|
46
|
+
context: PWBrowserContext | null;
|
|
47
|
+
targets: Map<string, TargetLease>;
|
|
48
|
+
createdAt: number;
|
|
49
|
+
lastActivity: number;
|
|
50
|
+
idleTtlMs: number;
|
|
51
|
+
maxLifetimeMs: number;
|
|
52
|
+
/** Set for owned EPHEMERAL sessions: deleted at teardown. */
|
|
53
|
+
ephemeralUserDataDir: string | null;
|
|
54
|
+
cancelled: boolean;
|
|
55
|
+
}
|
|
56
|
+
export declare class SessionError extends Error {
|
|
57
|
+
readonly code: "session_not_found" | "session_crashed" | "session_closed" | "target_not_found" | "stale_ref" | "lease_conflict";
|
|
58
|
+
constructor(code: SessionError["code"], message: string);
|
|
59
|
+
}
|
|
60
|
+
export declare function spaceUserDataDir(space: SpaceSpec, runId: string): {
|
|
61
|
+
dir: string;
|
|
62
|
+
ephemeral: boolean;
|
|
63
|
+
};
|
|
64
|
+
export declare class SessionManager {
|
|
65
|
+
private sessions;
|
|
66
|
+
private sweeper;
|
|
67
|
+
private log;
|
|
68
|
+
constructor(opts?: {
|
|
69
|
+
log?: (m: string) => void;
|
|
70
|
+
});
|
|
71
|
+
/** Create the session record; the caller (bridge) supplies the live
|
|
72
|
+
* Playwright handles once launch/attach succeeds. */
|
|
73
|
+
createSession(opts: {
|
|
74
|
+
runId: string;
|
|
75
|
+
ownership: SessionOwnership;
|
|
76
|
+
engine: string;
|
|
77
|
+
space: SpaceSpec;
|
|
78
|
+
idleTtlMs?: number;
|
|
79
|
+
maxLifetimeMs?: number;
|
|
80
|
+
}): BrowserSessionRecord;
|
|
81
|
+
attachHandles(rec: BrowserSessionRecord, handles: {
|
|
82
|
+
browser?: PWBrowser | null;
|
|
83
|
+
context: PWBrowserContext;
|
|
84
|
+
ephemeralUserDataDir?: string | null;
|
|
85
|
+
}): void;
|
|
86
|
+
getSession(runId: string): BrowserSessionRecord;
|
|
87
|
+
peekSession(runId: string): BrowserSessionRecord | undefined;
|
|
88
|
+
touch(rec: BrowserSessionRecord): void;
|
|
89
|
+
leaseTarget(rec: BrowserSessionRecord, ref: string, page: PWPage): TargetLease;
|
|
90
|
+
getLease(rec: BrowserSessionRecord, ref: string): TargetLease;
|
|
91
|
+
/** New snapshot -> new generation; every previously issued @eN ref
|
|
92
|
+
* becomes stale by construction. */
|
|
93
|
+
beginSnapshot(lease: TargetLease): number;
|
|
94
|
+
bindRef(lease: TargetLease, binding: BoundRef): void;
|
|
95
|
+
/** Resolve an @eN ref, failing closed on ANY generation mismatch. */
|
|
96
|
+
resolveRef(lease: TargetLease, ref: string): BoundRef;
|
|
97
|
+
/** Serialize an operation on a target (one write at a time per lease). */
|
|
98
|
+
runOnTarget<T>(lease: TargetLease, op: () => Promise<T>): Promise<T>;
|
|
99
|
+
/** Tear down a run's session. Closes ONLY owned contexts/browsers; an
|
|
100
|
+
* attached (user-owned) browser is disconnected from, never closed.
|
|
101
|
+
* Idempotent; safe on every terminal path. */
|
|
102
|
+
teardownRun(runId: string, reason: string): Promise<void>;
|
|
103
|
+
teardownAll(reason: string): Promise<void>;
|
|
104
|
+
/** TTL sweep: idle timeout + hard max lifetime. */
|
|
105
|
+
private sweep;
|
|
106
|
+
dispose(): void;
|
|
107
|
+
}
|
|
108
|
+
export {};
|