@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.
@@ -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", { capabilities: ["assistant_session"], package: "@melaya/runner", version: RUNNER_VERSION, assistantProtocol: ASSISTANT_PROTOCOL_VERSION });
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({ turnId: payload.turnId, message: payload.message, hitl_mode: hitlMode, static_context: typeof payload.staticContext === "string" ? payload.staticContext : "" }) + "\n");
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 {};