@melaya/runner 1.0.118 → 1.1.1
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 +954 -818
- package/dist/browserAuthz.d.ts +78 -0
- package/dist/browserAuthz.js +504 -0
- package/dist/browserBridge.d.ts +90 -0
- package/dist/browserBridge.js +1196 -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 +302 -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,89 @@ 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
|
+
});
|
|
298
|
+
// ── BrowserControlPage interactive browser launch (plan BrowserControlPage) ──
|
|
299
|
+
// The server emits browser:launch when the user clicks "Launch controllable
|
|
300
|
+
// browser" in the BrowserControlPage UI. Unlike runner:run browser grants,
|
|
301
|
+
// this is an unprivileged direct-launch (no grant, no CDP intercept) — the
|
|
302
|
+
// server just wants us to open a browser process the user can observe.
|
|
303
|
+
// State transitions are relayed back via browser:session so the server can
|
|
304
|
+
// update agents.browser_sessions.
|
|
305
|
+
socket.on("browser:launch", async (payload) => {
|
|
306
|
+
const sessionId = String(payload?.sessionId || "");
|
|
307
|
+
const engine = String(payload?.engine || "chrome");
|
|
308
|
+
const profile = String(payload?.profile || "dedicated");
|
|
309
|
+
if (!sessionId)
|
|
310
|
+
return;
|
|
311
|
+
if (opts.verbose) {
|
|
312
|
+
console.log(chalk.gray(` [browser-launch] sessionId=${sessionId.slice(0, 16)} engine=${engine} profile=${profile}`));
|
|
313
|
+
}
|
|
314
|
+
// Ensure the governed bridge is running before delegating.
|
|
315
|
+
const bridge = await _ensureBrowserBridge();
|
|
316
|
+
if (!bridge) {
|
|
317
|
+
console.log(chalk.yellow(` ! browser:launch: bridge unavailable for sessionId=${sessionId.slice(0, 16)}`));
|
|
318
|
+
socket.emit("browser:session", { sessionId, state: "failed", engine });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Delegate to the governed bridge. State transitions (active/closed/failed)
|
|
322
|
+
// are relayed back through the existing browser:session socket event.
|
|
323
|
+
await bridge.launchInteractive(sessionId, engine, profile, (sid, state, eng) => {
|
|
324
|
+
socket.emit("browser:session", { sessionId: sid, state, engine: eng });
|
|
325
|
+
if (opts.verbose) {
|
|
326
|
+
console.log(chalk.gray(` [browser-launch] browser:session sessionId=${sid.slice(0, 16)} state=${state}`));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
183
330
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
184
331
|
setInterval(() => {
|
|
185
332
|
if (socket.connected)
|
|
@@ -195,6 +342,71 @@ export async function connect(opts) {
|
|
|
195
342
|
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
196
343
|
return;
|
|
197
344
|
}
|
|
345
|
+
// ── Melaya Browser grant verification + bridge start ──────────────
|
|
346
|
+
// (plan Section 4) A browser run carries a signed browserGrant. We
|
|
347
|
+
// VERIFY it BEFORE any Python spawns and reject on ANY failure. The
|
|
348
|
+
// raw grant stays in runner memory (used to register the bridge run);
|
|
349
|
+
// only the localhost URL + a per-run bearer token reach the child.
|
|
350
|
+
let browserToken = null;
|
|
351
|
+
if (payload.browserGrant) {
|
|
352
|
+
try {
|
|
353
|
+
const grant = await verifyBrowserGrant(payload.browserGrant, {
|
|
354
|
+
expectedAud: runnerDeviceId,
|
|
355
|
+
publicKeysByKid: browserPublicKeys,
|
|
356
|
+
replayStore: browserReplayStore,
|
|
357
|
+
});
|
|
358
|
+
// Bind assertions (plan Section 4): the grant must target THIS
|
|
359
|
+
// runner device and THIS socket generation, and its run id must
|
|
360
|
+
// match the dispatch. Fail closed on any mismatch.
|
|
361
|
+
if (grant.runnerDevice !== runnerDeviceId) {
|
|
362
|
+
throw new BrowserGrantError("claims_invalid", `grant runnerDevice '${grant.runnerDevice}' != this device`);
|
|
363
|
+
}
|
|
364
|
+
if (grant.socketGeneration !== socketGeneration) {
|
|
365
|
+
throw new BrowserGrantError("claims_invalid", `grant socketGeneration ${grant.socketGeneration} != current ${socketGeneration}`);
|
|
366
|
+
}
|
|
367
|
+
if (grant.run !== payload.runId) {
|
|
368
|
+
throw new BrowserGrantError("claims_invalid", `grant run '${grant.run}' != dispatched run '${payload.runId}'`);
|
|
369
|
+
}
|
|
370
|
+
const bridge = await _ensureBrowserBridge();
|
|
371
|
+
if (!bridge)
|
|
372
|
+
throw new BrowserGrantError("claims_invalid", "browser bridge unavailable");
|
|
373
|
+
const spec = {
|
|
374
|
+
runId: payload.runId,
|
|
375
|
+
grant,
|
|
376
|
+
mode: payload.browser?.mode === "attach" ? "attach" : "launch",
|
|
377
|
+
engine: payload.browser?.engine,
|
|
378
|
+
cdpWsEndpoint: payload.browser?.cdpWsEndpoint,
|
|
379
|
+
space: payload.browser?.space ?? { kind: "ephemeral" },
|
|
380
|
+
codeMode: payload.browser?.codeMode === true,
|
|
381
|
+
headless: payload.browser?.headless === true,
|
|
382
|
+
};
|
|
383
|
+
const { token } = bridge.registerRun(spec);
|
|
384
|
+
browserToken = token;
|
|
385
|
+
console.log(chalk.hex("#7C6FF0")(` 🌐 Browser grant verified (mode=${spec.mode}, engine=${spec.engine || "chrome"}, ceiling=${grant.effectCeiling})`));
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
const code = e instanceof BrowserGrantError ? e.code : "grant_error";
|
|
389
|
+
console.log(chalk.red(` ✗ Browser grant rejected [${code}]: ${e?.message || e}`));
|
|
390
|
+
socket.emit("runner:event", {
|
|
391
|
+
run_id: payload.runId,
|
|
392
|
+
event_type: "agent_message",
|
|
393
|
+
project: payload.project,
|
|
394
|
+
replyId: `browser-grant-${payload.runId}`,
|
|
395
|
+
replyName: "Runner",
|
|
396
|
+
replyRole: "system",
|
|
397
|
+
msg: {
|
|
398
|
+
id: `browser-grant-${payload.runId}`,
|
|
399
|
+
name: "Runner",
|
|
400
|
+
role: "system",
|
|
401
|
+
content: [{ type: "text", text: `Browser grant rejected (${code}). This run cannot control a browser.` }],
|
|
402
|
+
metadata: { browserGrantError: code },
|
|
403
|
+
timestamp: new Date().toISOString(),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
198
410
|
// Ensure shared modules are cached
|
|
199
411
|
await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
|
|
200
412
|
// Ensure the dedicated venv at ~/.melaya-runner/venv/ has
|
|
@@ -377,6 +589,11 @@ export async function connect(opts) {
|
|
|
377
589
|
if (k.startsWith("MEL_MODEL_"))
|
|
378
590
|
delete inherited[k];
|
|
379
591
|
}
|
|
592
|
+
// Reserved browser bridge env names are runner-owned and injected
|
|
593
|
+
// LAST below (plan Section 4). Scrub any operator-exported value so
|
|
594
|
+
// a stale/hostile MEL_BROWSER_* in the shell can't shadow ours.
|
|
595
|
+
for (const k of RESERVED_BROWSER_ENV)
|
|
596
|
+
delete inherited[k];
|
|
380
597
|
// certifi CA-bundle path resolved at venv setup. Set as both
|
|
381
598
|
// SSL_CERT_FILE (urllib / openssl) and REQUESTS_CA_BUNDLE (requests
|
|
382
599
|
// library) so EVERY HTTPS-using tool — gmail_send, slack_post_text,
|
|
@@ -462,6 +679,14 @@ export async function connect(opts) {
|
|
|
462
679
|
MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
|
|
463
680
|
}
|
|
464
681
|
: {}),
|
|
682
|
+
// ── Melaya Browser bridge vars, injected LAST (plan Section 4) ──
|
|
683
|
+
// These reserved names come AFTER payload.credentials so a
|
|
684
|
+
// credential can never overwrite them. Present only when this run
|
|
685
|
+
// passed browser-grant verification above; the raw grant is never
|
|
686
|
+
// placed in the child env (it stays in the bridge registration).
|
|
687
|
+
...(browserToken && browserBridge
|
|
688
|
+
? { MEL_BROWSER_URL: browserBridge.url, MEL_BROWSER_TOKEN: browserToken }
|
|
689
|
+
: {}),
|
|
465
690
|
};
|
|
466
691
|
// Surface the tier to the chat panel as a system message so the
|
|
467
692
|
// operator sees WHY a small model is running with tighter caps.
|
|
@@ -518,6 +743,12 @@ export async function connect(opts) {
|
|
|
518
743
|
});
|
|
519
744
|
proc.on("exit", (code) => {
|
|
520
745
|
activeProcesses.delete(payload.runId);
|
|
746
|
+
// Melaya Browser terminal path (plan Section 7): the pipeline
|
|
747
|
+
// process exited, so revoke the grant, cancel in-flight browser
|
|
748
|
+
// ops, release leases, and close ONLY owned browsers. Idempotent.
|
|
749
|
+
if (browserBridge && browserBridge.hasRun(payload.runId)) {
|
|
750
|
+
browserBridge.teardownRun(payload.runId, `run_exit:${code}`).catch(() => { });
|
|
751
|
+
}
|
|
521
752
|
const status = code === 0 ? "done" : "failed";
|
|
522
753
|
// On non-zero exit, relay the captured stderr tail to the FE
|
|
523
754
|
// as a system agent_message so the operator sees the actual
|
|
@@ -987,6 +1218,14 @@ export async function connect(opts) {
|
|
|
987
1218
|
// (desktop drives the paired phone remotely). The host ORs this with the
|
|
988
1219
|
// mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
|
|
989
1220
|
MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
|
|
1221
|
+
// Melaya Browser (plan 0.4): the CAPABILITY flag. Presence means the
|
|
1222
|
+
// runner advertises browser control and the browser toolkit should be
|
|
1223
|
+
// included in the host's toolkit. The TARGET GRANT is NEVER in the boot
|
|
1224
|
+
// env; it arrives per-turn on the turn frame and is purged at turn end.
|
|
1225
|
+
// This is part of the config hash so the host reboots if capability
|
|
1226
|
+
// presence changes (a runner that gains/loses browser control restarts
|
|
1227
|
+
// the host automatically via config-drift detection above).
|
|
1228
|
+
MEL_ASSISTANT_BROWSER_CAPABLE: "1",
|
|
990
1229
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
991
1230
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
992
1231
|
// are reachable without exploding context.
|
|
@@ -1149,7 +1388,7 @@ export async function connect(opts) {
|
|
|
1149
1388
|
}
|
|
1150
1389
|
catch { /* best-effort — host stays as-is on a write failure */ }
|
|
1151
1390
|
});
|
|
1152
|
-
socket.on("runner:assistant_turn", (payload) => {
|
|
1391
|
+
socket.on("runner:assistant_turn", async (payload) => {
|
|
1153
1392
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
1154
1393
|
if (!s) {
|
|
1155
1394
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
|
|
@@ -1171,8 +1410,56 @@ export async function connect(opts) {
|
|
|
1171
1410
|
const hitlMode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
|
|
1172
1411
|
// static_context (persona / standing instructions) is forwarded per turn; the
|
|
1173
1412
|
// host folds it into its system prompt before running (parity with cloud).
|
|
1413
|
+
//
|
|
1414
|
+
// Melaya Browser per-turn grant (plan 0.4): verify the grant ON THIS
|
|
1415
|
+
// RUNNER before forwarding to the host. On failure, send an error event
|
|
1416
|
+
// and abort the turn — never inject an unverified grant token into the
|
|
1417
|
+
// host environment. On success, forward the raw compact JWS token plus
|
|
1418
|
+
// the target ref so the host's _apply_browser_turn_grant can install
|
|
1419
|
+
// them for the duration of the turn and purge them afterward.
|
|
1420
|
+
let verifiedGrantToken = null;
|
|
1421
|
+
let browserTargetRef = null;
|
|
1422
|
+
if (payload.browserGrant) {
|
|
1423
|
+
try {
|
|
1424
|
+
const grant = await verifyBrowserGrant(payload.browserGrant, {
|
|
1425
|
+
expectedAud: runnerDeviceId,
|
|
1426
|
+
publicKeysByKid: browserPublicKeys,
|
|
1427
|
+
replayStore: browserReplayStore,
|
|
1428
|
+
});
|
|
1429
|
+
// Bind assertions: must target this device and socket generation.
|
|
1430
|
+
if (grant.runnerDevice !== runnerDeviceId) {
|
|
1431
|
+
throw new BrowserGrantError("claims_invalid", `grant runnerDevice mismatch: ${grant.runnerDevice}`);
|
|
1432
|
+
}
|
|
1433
|
+
if (grant.socketGeneration !== socketGeneration) {
|
|
1434
|
+
throw new BrowserGrantError("claims_invalid", `grant socketGeneration ${grant.socketGeneration} != current ${socketGeneration}`);
|
|
1435
|
+
}
|
|
1436
|
+
// Accept the compact JWS token (the host's browser toolkit verifies
|
|
1437
|
+
// it locally via MEL_BROWSER_TURN_GRANT at call time, fail closed).
|
|
1438
|
+
verifiedGrantToken = payload.browserGrant;
|
|
1439
|
+
browserTargetRef = String(payload.browserTargetRef || grant.target.ref || "");
|
|
1440
|
+
if (opts.verbose) {
|
|
1441
|
+
console.log(chalk.gray(` [browser-bridge] turn grant ok (session=${payload.sessionId.slice(0, 10)} ceiling=${grant.effectCeiling})`));
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
catch (e) {
|
|
1445
|
+
const code = e instanceof BrowserGrantError ? e.code : "grant_error";
|
|
1446
|
+
console.log(chalk.yellow(` [browser-bridge] turn grant rejected [${code}]: ${e?.message || e}`));
|
|
1447
|
+
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `browser turn grant rejected (${code})` });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1174
1451
|
try {
|
|
1175
|
-
s.proc.stdin?.write(JSON.stringify({
|
|
1452
|
+
s.proc.stdin?.write(JSON.stringify({
|
|
1453
|
+
turnId: payload.turnId,
|
|
1454
|
+
message: payload.message,
|
|
1455
|
+
hitl_mode: hitlMode,
|
|
1456
|
+
static_context: typeof payload.staticContext === "string" ? payload.staticContext : "",
|
|
1457
|
+
// Per-turn browser grant: forwarded to the host as the raw compact
|
|
1458
|
+
// JWS string so the Python browser toolkit can verify it again
|
|
1459
|
+
// (defence-in-depth) and install MEL_BROWSER_TURN_GRANT in the
|
|
1460
|
+
// host env for THIS turn only. Absent when no grant was provided.
|
|
1461
|
+
...(verifiedGrantToken ? { browser_grant: verifiedGrantToken, browser_target_ref: browserTargetRef ?? "" } : {}),
|
|
1462
|
+
}) + "\n");
|
|
1176
1463
|
}
|
|
1177
1464
|
catch (e) {
|
|
1178
1465
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
|
|
@@ -1727,6 +2014,12 @@ export async function connect(opts) {
|
|
|
1727
2014
|
activeProcesses.delete(data.runId);
|
|
1728
2015
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
1729
2016
|
}
|
|
2017
|
+
// Melaya Browser terminal path (plan Section 7): an explicit kill
|
|
2018
|
+
// revokes the grant and closes owned browsers even if the Python proc
|
|
2019
|
+
// was already gone. Idempotent.
|
|
2020
|
+
if (browserBridge && browserBridge.hasRun(data.runId)) {
|
|
2021
|
+
browserBridge.teardownRun(data.runId, "runner_kill").catch(() => { });
|
|
2022
|
+
}
|
|
1730
2023
|
});
|
|
1731
2024
|
// ── Pause / resume crew strategy (local-runner only) ───────────────
|
|
1732
2025
|
// For local-runner crew strategies, the Redis-flag pause path used by
|
|
@@ -1780,6 +2073,12 @@ export async function connect(opts) {
|
|
|
1780
2073
|
catch { /* noop */ }
|
|
1781
2074
|
}
|
|
1782
2075
|
activeAssistants.clear();
|
|
2076
|
+
// Melaya Browser terminal path (plan Section 7): shut the bridge down,
|
|
2077
|
+
// which cancels in-flight ops, releases every lease, and closes ONLY
|
|
2078
|
+
// owned browsers (attached user browsers are merely disconnected).
|
|
2079
|
+
if (browserBridge) {
|
|
2080
|
+
browserBridge.shutdown().catch(() => { });
|
|
2081
|
+
}
|
|
1783
2082
|
relay?.close();
|
|
1784
2083
|
// Best-effort Chromium teardown. The bridge's shutdown is async but
|
|
1785
2084
|
// 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 {};
|