@yeaft/webchat-agent 1.0.416 → 1.0.418

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.
Files changed (31) hide show
  1. package/browser-runtime/browser-install.js +39 -0
  2. package/browser-runtime/chromium.js +192 -0
  3. package/browser-runtime/cli.js +1 -1
  4. package/browser-runtime/extension/manifest.json +3 -0
  5. package/browser-runtime/extension/offscreen.js +201 -25
  6. package/browser-runtime/extension/popup.js +4 -1
  7. package/browser-runtime/extension/service-worker.js +47 -7
  8. package/browser-runtime/extension.js +1 -1
  9. package/browser-runtime/index.js +3 -0
  10. package/browser-runtime/local-bridge.js +194 -0
  11. package/browser-runtime/messages.js +123 -0
  12. package/browser-runtime/service.js +769 -30
  13. package/connection/index.js +2 -0
  14. package/connection/message-router.js +2 -0
  15. package/index.js +13 -0
  16. package/local-runtime/server/.env.example +10 -0
  17. package/local-runtime/server/browser-runtime-routes.js +284 -0
  18. package/local-runtime/server/client-protocol.js +8 -0
  19. package/local-runtime/server/config.js +28 -0
  20. package/local-runtime/server/handlers/agent-browser.js +393 -0
  21. package/local-runtime/server/handlers/agent-sync.js +14 -0
  22. package/local-runtime/server/handlers/client-browser.js +337 -0
  23. package/local-runtime/server/ws-agent.js +9 -1
  24. package/local-runtime/server/ws-client.js +41 -2
  25. package/local-runtime/version.json +1 -1
  26. package/local-runtime/web/app.bundle.js +220 -98
  27. package/local-runtime/web/app.bundle.js.gz +0 -0
  28. package/local-runtime/web/index.html +2 -2
  29. package/local-runtime/web/style.bundle.css +1 -1
  30. package/local-runtime/web/style.bundle.css.gz +0 -0
  31. package/package.json +1 -1
@@ -1,48 +1,250 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { normaliseBrowserRuntimeSection } from './config.js';
3
- import { defaultBrowserCacheDir } from './browser-install.js';
3
+ import {
4
+ defaultBrowserCacheDir,
5
+ installManagedBrowser,
6
+ managedBrowserDownloadInfo,
7
+ resolveBrowserExecutable,
8
+ } from './browser-install.js';
4
9
  import { probeBrowserRuntime } from './probe.js';
5
10
  import { BrowserRuntimeError } from './errors.js';
11
+ import { BrowserExtensionBridge } from './local-bridge.js';
12
+ import { launchBrowserSession } from './chromium.js';
6
13
 
7
- /**
8
- * Agent-local Browser Runtime lifecycle owner. Phase 0 exposes startup probe and
9
- * capacity semantics only; Browser Sessions remain unavailable until the later
10
- * owner-checked control-plane phase lands.
11
- */
14
+ const REQUEST_TTL_MS = 10 * 60_000;
15
+ const MAX_INSTALL_PROGRESS_LISTENERS = 8;
16
+
17
+ function clean(value, max = 512) {
18
+ return typeof value === 'string' ? value.trim().slice(0, max) : '';
19
+ }
20
+
21
+ function positiveInteger(value) {
22
+ return Number.isSafeInteger(value) && value > 0;
23
+ }
24
+
25
+ function safeInitialUrl(value) {
26
+ const raw = clean(value, 4096) || 'about:blank';
27
+ if (raw === 'about:blank') return raw;
28
+ try {
29
+ const url = new URL(raw);
30
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
31
+ throw new BrowserRuntimeError('browser_url_invalid');
32
+ }
33
+ return url.href;
34
+ } catch (error) {
35
+ if (error instanceof BrowserRuntimeError) throw error;
36
+ throw new BrowserRuntimeError('browser_url_invalid');
37
+ }
38
+ }
39
+
40
+ function stableDigest(value) {
41
+ return createHash('sha256').update(JSON.stringify(value || null)).digest('hex');
42
+ }
43
+
44
+ function normalizeIdentity(value) {
45
+ const identity = value && typeof value === 'object' ? value : {};
46
+ const result = Object.freeze({
47
+ ownerUserId: clean(identity.ownerUserId),
48
+ clientId: clean(identity.clientId),
49
+ webConnectionId: clean(identity.webConnectionId),
50
+ webConnectionGeneration: clean(identity.webConnectionGeneration),
51
+ });
52
+ if (!result.ownerUserId || !result.clientId || !result.webConnectionId || !result.webConnectionGeneration) {
53
+ throw new BrowserRuntimeError('browser_identity_required');
54
+ }
55
+ return result;
56
+ }
57
+
58
+ function sameOwner(a, b) {
59
+ return a?.ownerUserId === b?.ownerUserId;
60
+ }
61
+
62
+ function sameConnection(a, b) {
63
+ return sameOwner(a, b)
64
+ && a?.clientId === b?.clientId
65
+ && a?.webConnectionId === b?.webConnectionId
66
+ && a?.webConnectionGeneration === b?.webConnectionGeneration;
67
+ }
68
+
69
+ function publicIceServers(value) {
70
+ if (!Array.isArray(value)) return [];
71
+ return value.slice(0, 8).map(server => ({
72
+ urls: Array.isArray(server?.urls)
73
+ ? server.urls.slice(0, 8).map(url => clean(url, 2048)).filter(Boolean)
74
+ : clean(server?.urls, 2048),
75
+ ...(clean(server?.username, 512) ? { username: clean(server.username, 512) } : {}),
76
+ ...(clean(server?.credential, 1024) ? { credential: clean(server.credential, 1024) } : {}),
77
+ })).filter(server => Array.isArray(server.urls) ? server.urls.length > 0 : !!server.urls);
78
+ }
79
+
80
+ function sourceRef(value) {
81
+ if (!value || typeof value !== 'object') return null;
82
+ const kind = ['yeaft-session', 'chat-conversation', 'work-item'].includes(value.kind) ? value.kind : null;
83
+ if (!kind) return null;
84
+ return Object.freeze({
85
+ kind,
86
+ ...(clean(value.sessionId) ? { sessionId: clean(value.sessionId) } : {}),
87
+ ...(clean(value.conversationId) ? { conversationId: clean(value.conversationId) } : {}),
88
+ ...(clean(value.workItemId) ? { workItemId: clean(value.workItemId) } : {}),
89
+ });
90
+ }
91
+
92
+ /** Agent-local owner for Browser Session, Chromium and WebRTC peer lifecycles. */
12
93
  export class BrowserRuntimeService {
13
- constructor({ yeaftDir, config, probe = probeBrowserRuntime } = {}) {
94
+ constructor({
95
+ yeaftDir,
96
+ config,
97
+ probe = probeBrowserRuntime,
98
+ resolveBrowser = resolveBrowserExecutable,
99
+ installBrowser = installManagedBrowser,
100
+ downloadInfo = managedBrowserDownloadInfo,
101
+ saveSettings = null,
102
+ onCapabilitiesChanged = null,
103
+ bridge = new BrowserExtensionBridge(),
104
+ launchSession = launchBrowserSession,
105
+ send = null,
106
+ platform = process.platform,
107
+ arch = process.arch,
108
+ } = {}) {
14
109
  if (!yeaftDir) throw new Error('yeaftDir required');
15
110
  this.yeaftDir = yeaftDir;
16
111
  this.config = normaliseBrowserRuntimeSection(config);
17
112
  this.config.cacheDir ||= defaultBrowserCacheDir(yeaftDir);
18
113
  this.probe = probe;
114
+ this.resolveBrowser = resolveBrowser;
115
+ this.installBrowser = installBrowser;
116
+ this.downloadInfo = downloadInfo;
117
+ this.saveSettings = typeof saveSettings === 'function' ? saveSettings : null;
118
+ this.onCapabilitiesChanged = typeof onCapabilitiesChanged === 'function' ? onCapabilitiesChanged : null;
119
+ this.bridge = bridge;
120
+ this.launchSession = launchSession;
121
+ this.send = typeof send === 'function' ? send : () => 'dropped';
122
+ this.platform = platform;
123
+ this.arch = arch;
124
+ const download = this.downloadInfo({ platform, arch });
125
+ this.setupInfo = Object.freeze({
126
+ ...download,
127
+ // Phase 1 advertises only the real Linux tab-capture data plane. The CLI
128
+ // may manage pinned binaries on more platforms, but Web must not offer an
129
+ // install that can never produce a ready capability.
130
+ supported: download.supported === true && platform === 'linux' && arch === 'x64',
131
+ });
19
132
  this.sessions = new Map();
133
+ this.requests = new Map();
20
134
  this.probeResult = null;
135
+ this.installProgress = null;
136
+ this.lastSetupError = null;
21
137
  this.state = this.config.enabled ? 'unprobed' : 'disabled';
22
138
  this.#probePromise = null;
23
139
  this.#probeAbort = null;
140
+ this.#installPromise = null;
141
+ this.#installAbort = null;
142
+ this.#installListeners = new Set();
143
+ this.#lastInstallProgressAt = 0;
24
144
  this.#shutdownPromise = null;
25
145
  }
26
146
 
27
147
  #probePromise;
28
148
  #probeAbort;
149
+ #installPromise;
150
+ #installAbort;
151
+ #installListeners;
152
+ #lastInstallProgressAt;
29
153
  #shutdownPromise;
30
154
 
31
155
  get enabled() { return this.config.enabled === true; }
32
156
  get ready() { return this.state === 'ready' && this.probeResult?.ok === true; }
33
157
 
158
+ setupCapabilities() {
159
+ return this.setupInfo.supported ? ['browser_runtime_setup'] : [];
160
+ }
161
+
34
162
  capabilities() {
35
- // Phase 0 is a local spike only. Advertising the Phase 1 feature before
36
- // owner-checked create/attach/signaling exists would expose a dead route.
37
- return [];
163
+ if (!this.ready || this.platform !== 'linux' || this.probeResult?.captureMode !== 'tab') return [];
164
+ return ['browser_runtime', 'browser_webrtc', 'browser_capture_tab'];
38
165
  }
39
166
 
40
- async startupProbe() {
167
+ async #notifyCapabilitiesChanged() {
168
+ try {
169
+ await this.onCapabilitiesChanged?.();
170
+ } catch (error) {
171
+ console.warn(`[BrowserRuntime] capability refresh failed: ${error?.message || error}`);
172
+ }
173
+ }
174
+
175
+ async #persistEnabled({ managed = false } = {}) {
176
+ const update = {
177
+ enabled: true,
178
+ ...(managed ? { executablePath: null } : {}),
179
+ };
180
+ if (this.saveSettings) {
181
+ const saved = await this.saveSettings(update);
182
+ if (saved?.error) {
183
+ throw new BrowserRuntimeError('browser_config_update_failed', saved.error);
184
+ }
185
+ this.config = {
186
+ ...this.config,
187
+ ...normaliseBrowserRuntimeSection(saved),
188
+ cacheDir: this.config.cacheDir,
189
+ };
190
+ } else {
191
+ this.config = { ...this.config, ...update };
192
+ }
193
+ this.config.enabled = true;
194
+ if (managed) this.config.executablePath = null;
195
+ }
196
+
197
+ async setupStatus() {
198
+ let executablePath = null;
199
+ if (this.setupInfo.supported) {
200
+ try {
201
+ executablePath = await this.resolveBrowser({
202
+ executablePath: this.config.executablePath,
203
+ cacheDir: this.config.cacheDir,
204
+ });
205
+ if (this.lastSetupError?.source === 'status') this.lastSetupError = null;
206
+ } catch (error) {
207
+ if (!this.lastSetupError || this.lastSetupError.source === 'status') {
208
+ this.lastSetupError = {
209
+ source: 'status',
210
+ code: error?.code || 'browser_status_failed',
211
+ safeError: String(error?.message || error).slice(0, 500),
212
+ };
213
+ }
214
+ }
215
+ }
216
+ const installed = !!executablePath;
217
+ if (this.ready) this.lastSetupError = null;
218
+ const state = !this.setupInfo.supported ? 'unsupported'
219
+ : this.ready ? 'ready'
220
+ : this.#installPromise ? 'installing'
221
+ : this.state === 'probing' ? 'probing'
222
+ : this.probeResult && this.config.enabled ? 'unavailable'
223
+ : installed ? 'disabled'
224
+ : 'not_installed';
225
+ return Object.freeze({
226
+ supported: this.setupInfo.supported,
227
+ state,
228
+ installed,
229
+ enabled: this.enabled,
230
+ ready: this.ready,
231
+ buildId: this.setupInfo.buildId,
232
+ platform: this.setupInfo.platform,
233
+ downloadBytes: this.setupInfo.downloadBytes,
234
+ downloadedBytes: Number(this.installProgress?.downloadedBytes) || 0,
235
+ totalBytes: Number(this.installProgress?.totalBytes) || this.setupInfo.downloadBytes,
236
+ probeCode: this.probeResult?.code || null,
237
+ safeError: this.lastSetupError?.safeError || this.probeResult?.safeError || null,
238
+ });
239
+ }
240
+
241
+ async startupProbe({ force = false } = {}) {
41
242
  if (!this.enabled) return { ok: false, code: 'browser_runtime_disabled' };
42
243
  if (this.#probePromise) return this.#probePromise;
244
+ if (!force && this.ready) return this.probeResult;
43
245
  this.state = 'probing';
44
246
  this.#probeAbort = new AbortController();
45
- this.#probePromise = this.probe({
247
+ const probePromise = this.probe({
46
248
  executablePath: this.config.executablePath,
47
249
  cacheDir: this.config.cacheDir,
48
250
  headless: this.config.headless,
@@ -61,33 +263,559 @@ export class BrowserRuntimeService {
61
263
  });
62
264
  this.state = 'unavailable';
63
265
  return this.probeResult;
266
+ }).finally(() => {
267
+ if (this.#probePromise === probePromise) this.#probePromise = null;
268
+ this.#probeAbort = null;
64
269
  });
65
- return this.#probePromise;
270
+ this.#probePromise = probePromise;
271
+ return probePromise;
272
+ }
273
+
274
+ async enableAndProbe() {
275
+ if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
276
+ this.lastSetupError = null;
277
+ try {
278
+ const executablePath = await this.resolveBrowser({
279
+ executablePath: this.config.executablePath,
280
+ cacheDir: this.config.cacheDir,
281
+ });
282
+ if (!executablePath) throw new BrowserRuntimeError('browser_executable_missing');
283
+ await this.#persistEnabled();
284
+ this.probeResult = null;
285
+ const probe = await this.startupProbe({ force: true });
286
+ await this.#notifyCapabilitiesChanged();
287
+ return { ...(await this.setupStatus()), probeCode: probe.code || null };
288
+ } catch (error) {
289
+ this.lastSetupError = {
290
+ source: 'enable',
291
+ code: error?.code || 'browser_enable_failed',
292
+ safeError: String(error?.message || error).slice(0, 500),
293
+ };
294
+ throw error;
295
+ }
296
+ }
297
+
298
+ async installAndEnable({ confirmedBuildId, confirmedDownloadBytes, onProgress = null } = {}) {
299
+ if (!this.setupInfo.supported) throw new BrowserRuntimeError('browser_platform_unsupported');
300
+ if (confirmedBuildId !== this.setupInfo.buildId
301
+ || Number(confirmedDownloadBytes) !== this.setupInfo.downloadBytes) {
302
+ throw new BrowserRuntimeError('browser_install_confirmation_stale');
303
+ }
304
+ if (typeof onProgress === 'function') {
305
+ if (this.#installListeners.size >= MAX_INSTALL_PROGRESS_LISTENERS) {
306
+ throw new BrowserRuntimeError('browser_install_observer_limit');
307
+ }
308
+ this.#installListeners.add(onProgress);
309
+ }
310
+ if (!this.#installPromise) {
311
+ this.state = 'installing';
312
+ this.lastSetupError = null;
313
+ this.installProgress = Object.freeze({
314
+ downloadedBytes: 0,
315
+ totalBytes: this.setupInfo.downloadBytes,
316
+ });
317
+ this.#installAbort = new AbortController();
318
+ const installPromise = (async () => {
319
+ await this.installBrowser({
320
+ cacheDir: this.config.cacheDir,
321
+ signal: this.#installAbort.signal,
322
+ onProgress: (downloadedBytes, totalBytes) => {
323
+ const total = Number(totalBytes) || this.setupInfo.downloadBytes;
324
+ this.installProgress = Object.freeze({
325
+ downloadedBytes: Number(downloadedBytes) || 0,
326
+ totalBytes: total,
327
+ });
328
+ const now = Date.now();
329
+ if (now - this.#lastInstallProgressAt < 250 && downloadedBytes < total) return;
330
+ this.#lastInstallProgressAt = now;
331
+ for (const listener of this.#installListeners) {
332
+ try {
333
+ Promise.resolve(listener(this.installProgress)).catch(() => {});
334
+ } catch {}
335
+ }
336
+ },
337
+ });
338
+ await this.#persistEnabled({ managed: true });
339
+ this.probeResult = null;
340
+ await this.startupProbe({ force: true });
341
+ await this.#notifyCapabilitiesChanged();
342
+ return this.setupStatus();
343
+ })().catch(error => {
344
+ this.lastSetupError = {
345
+ source: 'install',
346
+ code: error?.code || 'browser_install_failed',
347
+ safeError: String(error?.message || error).slice(0, 500),
348
+ };
349
+ this.state = 'unavailable';
350
+ throw error;
351
+ }).finally(() => {
352
+ if (this.#installPromise === installPromise) {
353
+ this.#installPromise = null;
354
+ this.#installAbort = null;
355
+ }
356
+ this.#installListeners.clear();
357
+ });
358
+ this.#installPromise = installPromise;
359
+ }
360
+ try {
361
+ return await this.#installPromise;
362
+ } finally {
363
+ if (typeof onProgress === 'function') this.#installListeners.delete(onProgress);
364
+ }
365
+ }
366
+
367
+ #emit(message) {
368
+ return this.send(message);
369
+ }
370
+
371
+ #pruneRequests(now = Date.now()) {
372
+ for (const [key, request] of this.requests) {
373
+ if (request.expiresAt <= now) this.requests.delete(key);
374
+ }
375
+ }
376
+
377
+ #requestKey(identity, requestId) {
378
+ return `${identity.ownerUserId}\0${identity.webConnectionId}\0${clean(requestId)}`;
379
+ }
380
+
381
+ #snapshot(session, extra = {}) {
382
+ return {
383
+ browserSessionId: session.browserSessionId,
384
+ revision: session.revision,
385
+ state: session.state,
386
+ activeUrl: session.activeUrl,
387
+ title: session.title,
388
+ pageRevision: session.pageRevision,
389
+ captureMode: session.captureMode,
390
+ viewport: session.viewport,
391
+ viewerCount: session.peers.size,
392
+ interactivePeerCount: 0,
393
+ authorizedProducerCount: 0,
394
+ expiresAt: session.expiresAt,
395
+ terminalReason: session.terminalReason || null,
396
+ safeError: session.safeError || null,
397
+ sourceRef: session.sourceRef,
398
+ ...extra,
399
+ };
400
+ }
401
+
402
+ #emitSnapshot(session, extra = {}) {
403
+ return this.#emit({ type: 'browser_session_snapshot', ...this.#snapshot(session, extra) });
404
+ }
405
+
406
+ #dropPeer(session, peer, reason = 'peer_closed') {
407
+ if (!peer || session.peers.get(peer.peerId) !== peer) return false;
408
+ clearTimeout(peer.expiryTimer);
409
+ peer.expiryTimer = null;
410
+ session.peers.delete(peer.peerId);
411
+ this.bridge.send(session.browserSessionId, {
412
+ type: 'peer_close',
413
+ peerId: peer.peerId,
414
+ connectionGeneration: peer.connectionGeneration,
415
+ reason,
416
+ });
417
+ session.revision += 1;
418
+ this.#scheduleNoViewerCleanup(session);
419
+ return true;
420
+ }
421
+
422
+ #scheduleNoViewerCleanup(session) {
423
+ clearTimeout(session.noViewerTimer);
424
+ session.noViewerTimer = null;
425
+ if (session.state !== 'ready' || session.peers.size > 0) return;
426
+ const delay = Math.max(10_000, Number(this.config.noViewerIdleMs) || 120_000);
427
+ session.expiresAt = Date.now() + delay;
428
+ session.noViewerTimer = setTimeout(() => {
429
+ if (this.sessions.get(session.browserSessionId) === session && session.peers.size === 0) {
430
+ void this.closeSessionRecord(session, 'no_viewer_timeout');
431
+ }
432
+ }, delay);
433
+ session.noViewerTimer.unref?.();
66
434
  }
67
435
 
68
436
  assertCanCreateSession() {
69
437
  if (!this.ready) throw new BrowserRuntimeError('browser_runtime_unavailable');
70
- if (this.sessions.size >= this.config.maxSessions) {
71
- throw new BrowserRuntimeError('browser_session_limit');
72
- }
438
+ if (this.sessions.size >= this.config.maxSessions) throw new BrowserRuntimeError('browser_session_limit');
73
439
  }
74
440
 
75
- /** Phase 0 test hook for lifecycle/capacity ownership. */
76
- reserveSession(ownerUserId) {
441
+ async createSession(message) {
442
+ const identity = normalizeIdentity(message?.serverIdentity);
443
+ const requestId = clean(message?.requestId);
444
+ if (!requestId) throw new BrowserRuntimeError('browser_request_id_required');
445
+ const options = {
446
+ initialUrl: safeInitialUrl(message?.options?.initialUrl),
447
+ viewport: message?.options?.viewport || null,
448
+ locale: clean(message?.options?.locale, 32) || 'en-US',
449
+ capturePreference: clean(message?.options?.capturePreference, 16) || 'auto',
450
+ };
451
+ if (!['auto', 'tab'].includes(options.capturePreference)) {
452
+ throw new BrowserRuntimeError('browser_capture_mode_unsupported');
453
+ }
454
+ this.#pruneRequests();
455
+ const requestKey = this.#requestKey(identity, requestId);
456
+ const digest = stableDigest({ options, sourceRef: sourceRef(message?.sourceRef) });
457
+ const existing = this.requests.get(requestKey);
458
+ if (existing) {
459
+ if (existing.digest !== digest) throw new BrowserRuntimeError('browser_request_conflict');
460
+ return existing.promise;
461
+ }
77
462
  this.assertCanCreateSession();
78
- if (!ownerUserId) throw new BrowserRuntimeError('browser_owner_required');
463
+
79
464
  const browserSessionId = randomUUID();
80
- this.sessions.set(browserSessionId, Object.freeze({
465
+ const session = {
81
466
  browserSessionId,
82
- ownerUserId,
83
- state: 'reserved',
84
- createdAt: Date.now(),
85
- }));
86
- return this.sessions.get(browserSessionId);
467
+ owner: identity,
468
+ sourceRef: sourceRef(message?.sourceRef),
469
+ revision: 1,
470
+ pageRevision: 1,
471
+ state: 'starting',
472
+ activeUrl: options.initialUrl,
473
+ title: '',
474
+ captureMode: 'tab',
475
+ viewport: null,
476
+ peers: new Map(),
477
+ runtime: null,
478
+ bridgeRegistration: null,
479
+ noViewerTimer: null,
480
+ expiresAt: null,
481
+ terminalReason: null,
482
+ safeError: null,
483
+ closingPromise: null,
484
+ startupAbort: new AbortController(),
485
+ };
486
+ this.sessions.set(browserSessionId, session);
487
+ const promise = this.#startSession(session, options, requestId);
488
+ this.requests.set(requestKey, { digest, promise, expiresAt: Date.now() + REQUEST_TTL_MS });
489
+ return promise;
490
+ }
491
+
492
+ async #startSession(session, options, requestId) {
493
+ try {
494
+ session.bridgeRegistration = await this.bridge.registerSession(session.browserSessionId, {
495
+ onMessage: message => this.#handleBridgeMessage(session, message),
496
+ onDisconnect: () => {
497
+ if (session.state === 'ready') void this.closeSessionRecord(session, 'extension_disconnected');
498
+ },
499
+ });
500
+ session.runtime = await this.launchSession({
501
+ browserSessionId: session.browserSessionId,
502
+ bridgeUrl: session.bridgeRegistration.bridgeUrl,
503
+ config: this.config,
504
+ initialUrl: options.initialUrl,
505
+ viewport: options.viewport,
506
+ locale: options.locale,
507
+ signal: session.startupAbort.signal,
508
+ });
509
+ if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
510
+ await session.runtime.close?.().catch(() => {});
511
+ session.runtime = null;
512
+ throw new BrowserRuntimeError('browser_session_cancelled');
513
+ }
514
+ await session.bridgeRegistration.waitUntilReady(this.config.startupProbeTimeoutMs);
515
+ if (this.sessions.get(session.browserSessionId) !== session || session.state !== 'starting') {
516
+ throw new BrowserRuntimeError('browser_session_cancelled');
517
+ }
518
+ session.viewport = session.runtime.viewport;
519
+ session.captureMode = session.runtime.captureMode;
520
+ session.activeUrl = session.runtime.page.url();
521
+ session.title = await session.runtime.page.title().catch(() => '');
522
+ session.state = 'ready';
523
+ session.revision += 1;
524
+ session.runtime.page.on('framenavigated', frame => {
525
+ if (frame !== session.runtime?.page?.mainFrame?.()) return;
526
+ session.pageRevision += 1;
527
+ session.revision += 1;
528
+ session.activeUrl = frame.url();
529
+ void session.runtime.page.title().then(title => { session.title = title; }).catch(() => {});
530
+ this.#emitSnapshot(session);
531
+ });
532
+ session.runtime.page.on('close', () => {
533
+ if (session.state === 'ready') void this.closeSessionRecord(session, 'page_closed');
534
+ });
535
+ this.#scheduleNoViewerCleanup(session);
536
+ const created = {
537
+ type: 'browser_session_created',
538
+ requestId,
539
+ ...this.#snapshot(session),
540
+ };
541
+ await this.#emit(created);
542
+ return created;
543
+ } catch (error) {
544
+ session.state = 'failed';
545
+ session.revision += 1;
546
+ session.terminalReason = error?.code || 'browser_session_start_failed';
547
+ session.safeError = String(error?.message || error).slice(0, 500);
548
+ await this.#emit({
549
+ type: 'browser_session_error',
550
+ requestId,
551
+ browserSessionId: session.browserSessionId,
552
+ code: session.terminalReason,
553
+ safeError: session.safeError,
554
+ });
555
+ await this.closeSessionRecord(session, session.terminalReason, { emit: false });
556
+ throw error;
557
+ }
558
+ }
559
+
560
+ #sessionFor(message, { exactConnection = false } = {}) {
561
+ const session = this.sessions.get(clean(message?.browserSessionId));
562
+ if (!session) throw new BrowserRuntimeError('browser_session_not_found');
563
+ const identity = normalizeIdentity(message?.serverIdentity);
564
+ if (!sameOwner(session.owner, identity)) throw new BrowserRuntimeError('browser_owner_mismatch');
565
+ if (exactConnection && !sameConnection(session.owner, identity)) {
566
+ // Peer messages are checked against the peer's connection below. Session
567
+ // creation ownership alone must not grant a sibling browser tab control.
568
+ }
569
+ return { session, identity };
570
+ }
571
+
572
+ async preparePeer(message) {
573
+ const { session, identity } = this.#sessionFor(message);
574
+ if (session.state !== 'ready') throw new BrowserRuntimeError('browser_session_unavailable');
575
+ const peerId = clean(message?.peerId);
576
+ const connectionGeneration = Number(message?.connectionGeneration);
577
+ if (!peerId || !positiveInteger(connectionGeneration)) throw new BrowserRuntimeError('browser_peer_invalid');
578
+ const existing = session.peers.get(peerId);
579
+ if (existing) {
580
+ if (existing.connectionGeneration !== connectionGeneration || !sameConnection(existing.identity, identity)) {
581
+ throw new BrowserRuntimeError('browser_peer_conflict');
582
+ }
583
+ if (existing.state === 'prepared' || existing.state === 'offered' || existing.state === 'connected') {
584
+ return this.#emit({
585
+ type: 'browser_peer_prepared',
586
+ browserSessionId: session.browserSessionId,
587
+ peerId,
588
+ connectionGeneration,
589
+ });
590
+ }
591
+ return true;
592
+ }
593
+ if (session.peers.size >= this.config.maxPeersPerSession) throw new BrowserRuntimeError('browser_peer_limit');
594
+ const peer = {
595
+ peerId,
596
+ connectionGeneration,
597
+ identity,
598
+ state: 'preparing',
599
+ webCandidateCount: 0,
600
+ agentCandidateCount: 0,
601
+ expiresAt: Number(message?.routeExpiresAt) || Date.now() + 10 * 60_000,
602
+ expiryTimer: null,
603
+ };
604
+ session.peers.set(peerId, peer);
605
+ peer.expiryTimer = setTimeout(() => {
606
+ if (this.#dropPeer(session, peer, 'peer_route_expired')) this.#emitSnapshot(session);
607
+ }, Math.max(1, peer.expiresAt - Date.now()));
608
+ peer.expiryTimer.unref?.();
609
+ clearTimeout(session.noViewerTimer);
610
+ session.noViewerTimer = null;
611
+ session.expiresAt = null;
612
+ const sent = this.bridge.send(session.browserSessionId, {
613
+ type: 'peer_prepare',
614
+ peerId,
615
+ connectionGeneration,
616
+ iceServers: publicIceServers(message?.agentIceServers),
617
+ iceTransportPolicy: message?.iceTransportPolicy === 'relay' ? 'relay' : 'all',
618
+ maxBitrate: this.config.maxBitrate,
619
+ maxFps: this.config.maxFps,
620
+ });
621
+ if (!sent) {
622
+ clearTimeout(peer.expiryTimer);
623
+ session.peers.delete(peerId);
624
+ this.#scheduleNoViewerCleanup(session);
625
+ throw new BrowserRuntimeError('browser_extension_unavailable');
626
+ }
627
+ return true;
628
+ }
629
+
630
+ #peerFor(message) {
631
+ const { session, identity } = this.#sessionFor(message);
632
+ const peer = session.peers.get(clean(message?.peerId));
633
+ if (!peer || peer.connectionGeneration !== Number(message?.connectionGeneration)) {
634
+ throw new BrowserRuntimeError('browser_peer_stale');
635
+ }
636
+ if (!sameConnection(peer.identity, identity)) throw new BrowserRuntimeError('browser_peer_owner_mismatch');
637
+ return { session, peer };
638
+ }
639
+
640
+ answerPeer(message) {
641
+ const { session, peer } = this.#peerFor(message);
642
+ const description = message?.description;
643
+ if (description?.type !== 'answer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
644
+ throw new BrowserRuntimeError('browser_sdp_invalid');
645
+ }
646
+ if (!this.bridge.send(session.browserSessionId, {
647
+ type: 'peer_answer',
648
+ peerId: peer.peerId,
649
+ connectionGeneration: peer.connectionGeneration,
650
+ description: { type: 'answer', sdp: description.sdp },
651
+ })) throw new BrowserRuntimeError('browser_extension_unavailable');
652
+ }
653
+
654
+ addPeerIceCandidate(message) {
655
+ const { session, peer } = this.#peerFor(message);
656
+ if (peer.webCandidateCount >= 128) throw new BrowserRuntimeError('browser_candidate_limit');
657
+ const candidate = message?.candidate;
658
+ if (candidate != null && (typeof candidate !== 'object'
659
+ || typeof candidate.candidate !== 'string'
660
+ || candidate.candidate.length > 4096)) {
661
+ throw new BrowserRuntimeError('browser_candidate_invalid');
662
+ }
663
+ peer.webCandidateCount += 1;
664
+ if (!this.bridge.send(session.browserSessionId, {
665
+ type: 'peer_ice_candidate',
666
+ peerId: peer.peerId,
667
+ connectionGeneration: peer.connectionGeneration,
668
+ candidate: candidate == null ? null : {
669
+ candidate: candidate.candidate,
670
+ sdpMid: clean(candidate.sdpMid, 256) || null,
671
+ sdpMLineIndex: Number.isInteger(candidate.sdpMLineIndex) ? candidate.sdpMLineIndex : null,
672
+ usernameFragment: clean(candidate.usernameFragment, 256) || null,
673
+ },
674
+ })) throw new BrowserRuntimeError('browser_extension_unavailable');
675
+ }
676
+
677
+ detachPeer(message, reason = 'peer_detached') {
678
+ const { session, peer } = this.#peerFor(message);
679
+ const dropped = this.#dropPeer(session, peer, reason);
680
+ if (dropped) this.#emitSnapshot(session);
681
+ return dropped;
682
+ }
683
+
684
+ async closeSession(message) {
685
+ const { session } = this.#sessionFor(message);
686
+ if (message?.expectedRevision != null && Number(message.expectedRevision) !== session.revision) {
687
+ throw new BrowserRuntimeError('browser_revision_conflict');
688
+ }
689
+ await this.closeSessionRecord(session, 'user_closed', { emit: false });
690
+ return this.#emit({
691
+ type: 'browser_session_snapshot',
692
+ requestId: clean(message?.requestId) || null,
693
+ ...this.#snapshot(session),
694
+ });
695
+ }
696
+
697
+ async closeSessionRecord(session, reason = 'closed', { emit = true } = {}) {
698
+ if (!session || this.sessions.get(session.browserSessionId) !== session) return false;
699
+ if (session.closingPromise) return session.closingPromise;
700
+ session.closingPromise = (async () => {
701
+ session.state = 'closing';
702
+ session.revision += 1;
703
+ session.startupAbort?.abort(new BrowserRuntimeError('browser_session_cancelled'));
704
+ session.terminalReason = reason;
705
+ clearTimeout(session.noViewerTimer);
706
+ session.noViewerTimer = null;
707
+ for (const peer of session.peers.values()) clearTimeout(peer.expiryTimer);
708
+ session.peers.clear();
709
+ this.bridge.send(session.browserSessionId, { type: 'session_close', reason });
710
+ this.bridge.unregisterSession(session.browserSessionId, reason);
711
+ try { await session.runtime?.close?.(); } catch {}
712
+ session.runtime = null;
713
+ if (this.sessions.get(session.browserSessionId) === session) {
714
+ this.sessions.delete(session.browserSessionId);
715
+ }
716
+ session.state = 'closed';
717
+ session.revision += 1;
718
+ session.expiresAt = null;
719
+ if (emit) await this.#emitSnapshot(session);
720
+ return true;
721
+ })();
722
+ return session.closingPromise;
723
+ }
724
+
725
+ getSession(message) {
726
+ const { session } = this.#sessionFor(message);
727
+ return this.#emit({
728
+ type: 'browser_session_snapshot',
729
+ requestId: clean(message?.requestId) || null,
730
+ ...this.#snapshot(session),
731
+ });
87
732
  }
88
733
 
89
- releaseSession(browserSessionId) {
90
- return this.sessions.delete(browserSessionId);
734
+ listSessions(message) {
735
+ const identity = normalizeIdentity(message?.serverIdentity);
736
+ const sessions = [...this.sessions.values()]
737
+ .filter(session => sameOwner(session.owner, identity))
738
+ .map(session => this.#snapshot(session));
739
+ return this.#emit({
740
+ type: 'browser_session_list_result',
741
+ requestId: clean(message?.requestId) || null,
742
+ sessions,
743
+ });
744
+ }
745
+
746
+ #handleBridgeMessage(session, message) {
747
+ if (this.sessions.get(session.browserSessionId) !== session) return;
748
+ if (message.type === 'peer_prepared' || message.type === 'peer_offer'
749
+ || message.type === 'peer_ice_candidate' || message.type === 'peer_state'
750
+ || message.type === 'peer_error') {
751
+ const peer = session.peers.get(clean(message.peerId));
752
+ if (!peer || peer.connectionGeneration !== Number(message.connectionGeneration)) return;
753
+ if (message.type === 'peer_prepared') {
754
+ peer.state = 'prepared';
755
+ void this.#emit({
756
+ type: 'browser_peer_prepared',
757
+ browserSessionId: session.browserSessionId,
758
+ peerId: peer.peerId,
759
+ connectionGeneration: peer.connectionGeneration,
760
+ });
761
+ } else if (message.type === 'peer_offer') {
762
+ const description = message.description;
763
+ if (description?.type !== 'offer' || typeof description.sdp !== 'string' || description.sdp.length > 96 * 1024) {
764
+ void this.closeSessionRecord(session, 'invalid_extension_offer');
765
+ return;
766
+ }
767
+ peer.state = 'offered';
768
+ void this.#emit({
769
+ type: 'browser_peer_offer',
770
+ browserSessionId: session.browserSessionId,
771
+ peerId: peer.peerId,
772
+ connectionGeneration: peer.connectionGeneration,
773
+ description: { type: 'offer', sdp: description.sdp },
774
+ });
775
+ } else if (message.type === 'peer_ice_candidate') {
776
+ if (peer.agentCandidateCount >= 128) return;
777
+ peer.agentCandidateCount += 1;
778
+ void this.#emit({
779
+ type: 'browser_peer_ice_candidate',
780
+ browserSessionId: session.browserSessionId,
781
+ peerId: peer.peerId,
782
+ connectionGeneration: peer.connectionGeneration,
783
+ candidate: message.candidate || null,
784
+ });
785
+ } else if (message.type === 'peer_error') {
786
+ this.#dropPeer(session, peer, 'peer_failed');
787
+ void this.#emit({
788
+ type: 'browser_peer_error',
789
+ browserSessionId: session.browserSessionId,
790
+ peerId: peer.peerId,
791
+ connectionGeneration: peer.connectionGeneration,
792
+ code: clean(message.code, 128) || 'peer_failed',
793
+ safeError: clean(message.safeError, 500) || 'Browser peer failed',
794
+ });
795
+ void this.#emitSnapshot(session);
796
+ } else {
797
+ const nextState = clean(message.state, 32) || peer.state;
798
+ if (nextState === 'connected') {
799
+ clearTimeout(peer.expiryTimer);
800
+ peer.expiryTimer = null;
801
+ peer.expiresAt = null;
802
+ peer.state = nextState;
803
+ } else if (['failed', 'disconnected', 'closed'].includes(nextState)) {
804
+ this.#dropPeer(session, peer, `peer_${nextState}`);
805
+ } else {
806
+ peer.state = nextState;
807
+ }
808
+ void this.#emit({
809
+ type: 'browser_peer_state',
810
+ browserSessionId: session.browserSessionId,
811
+ peerId: peer.peerId,
812
+ connectionGeneration: peer.connectionGeneration,
813
+ state: nextState,
814
+ });
815
+ }
816
+ return;
817
+ }
818
+ if (message.type === 'capture_ended') void this.closeSessionRecord(session, 'capture_ended');
91
819
  }
92
820
 
93
821
  snapshot() {
@@ -101,11 +829,22 @@ export class BrowserRuntimeService {
101
829
  });
102
830
  }
103
831
 
832
+ async handleTransportDisconnect() {
833
+ await Promise.allSettled([...this.sessions.values()]
834
+ .map(session => this.closeSessionRecord(session, 'agent_transport_disconnected', { emit: false })));
835
+ }
836
+
104
837
  async shutdown() {
105
838
  if (this.#shutdownPromise) return this.#shutdownPromise;
106
839
  this.#probeAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
107
- this.#shutdownPromise = Promise.resolve(this.#probePromise).catch(() => {}).then(() => {
108
- this.sessions.clear();
840
+ this.#installAbort?.abort(new BrowserRuntimeError('browser_runtime_shutdown'));
841
+ this.#shutdownPromise = Promise.allSettled([
842
+ Promise.resolve(this.#probePromise),
843
+ Promise.resolve(this.#installPromise),
844
+ ]).then(async () => {
845
+ await Promise.allSettled([...this.sessions.values()]
846
+ .map(session => this.closeSessionRecord(session, 'browser_runtime_shutdown', { emit: false })));
847
+ await this.bridge.close();
109
848
  this.state = 'closed';
110
849
  });
111
850
  return this.#shutdownPromise;