@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.
@@ -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,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({ turnId: payload.turnId, message: payload.message, hitl_mode: hitlMode, static_context: typeof payload.staticContext === "string" ? payload.staticContext : "" }) + "\n");
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 {};