@modelprofile.com/browser-runtime 5.0.0 → 5.2.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.
@@ -29,6 +29,8 @@ import type {
29
29
  IBrowserResourceRegistration,
30
30
  IBrowserRuntimeState,
31
31
  IBrowserRuntimeViewportResult,
32
+ IBrowserRuntimeEventSubscriptionOptions,
33
+ TBrowserRuntimeVideoOffer,
32
34
  IBrowserScreenshotResult,
33
35
  ICreateBrowserResourceRequest,
34
36
  TQualifiedBrowserSessionId,
@@ -102,6 +104,7 @@ interface INormalizedRuntimeOptions {
102
104
  maxOutstandingFrames: number;
103
105
  frameAcknowledgementTimeoutMs: number;
104
106
  screencast: INormalizedScreencastOptions;
107
+ video: Required<plugins.smartpuppeteer.ILiveVideoOptions>;
105
108
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
106
109
  ownership?: IBrowserRuntimeTestingOptions['ownership'];
107
110
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
@@ -138,6 +141,9 @@ interface ILeaseRecord {
138
141
  released: boolean;
139
142
  releasePromise?: Promise<void>;
140
143
  preferredViewport?: plugins.smartpuppeteer.ILiveBrowserViewport;
144
+ videoPeerId: string;
145
+ videoPeerMayExist: boolean;
146
+ frameCaptureUsed: boolean;
141
147
  heldKeys: Map<string, plugins.smartpuppeteer.ILiveBrowserKeyInput>;
142
148
  heldButtons: Map<string, plugins.smartpuppeteer.ILiveBrowserMouseInput>;
143
149
  }
@@ -175,7 +181,7 @@ interface IQueuedOperationRecord {
175
181
  state: 'queued' | 'starting' | 'active' | 'settled';
176
182
  onQueuedAbort?: () => void;
177
183
  /** Resource-owned cleanup, admitted only for this exact released participant. */
178
- participantCleanup?: boolean;
184
+ participantCleanup?: 'participant' | 'subscription' | false;
179
185
  }
180
186
 
181
187
  interface IOutstandingFrameRecord {
@@ -194,6 +200,10 @@ type TFrameAcknowledgementOutcome =
194
200
  interface IFrameSubscriptionRecord {
195
201
  lease: ILeaseRecord;
196
202
  listener: (event: TBrowserRuntimeEvent) => void;
203
+ includeFrames: boolean;
204
+ session: ILiveBrowserSessionLike;
205
+ closePromise?: Promise<void>;
206
+ closeComplete?: boolean;
197
207
  outstanding: Map<string, IOutstandingFrameRecord>;
198
208
  highestSequence: number;
199
209
  closed: boolean;
@@ -240,6 +250,7 @@ interface IResourceSlot {
240
250
  operationQueue: IQueuedOperationRecord[];
241
251
  operationSchedulerRunning: boolean;
242
252
  frameSubscriptions: Map<string, IFrameSubscriptionRecord>;
253
+ frameCaptureEnabled?: boolean;
243
254
  highestFrameSequence: number;
244
255
  producerAcknowledgements: Set<Promise<TFrameAcknowledgementOutcome>>;
245
256
  frameRefresh?: IFrameRefreshRecord;
@@ -558,6 +569,7 @@ export class BrowserRuntime {
558
569
  10_000,
559
570
  ),
560
571
  screencast,
572
+ video: plugins.smartpuppeteer.normalizeLiveVideoOptions(options.video ?? {}),
561
573
  egress: options.egress ?? {},
562
574
  ownership: testingOptions?.ownership,
563
575
  beforeLeasePublication: testingOptions?.beforeLeasePublication,
@@ -1352,6 +1364,46 @@ export class BrowserRuntime {
1352
1364
  });
1353
1365
  }
1354
1366
 
1367
+ /** @internal */
1368
+ public async leaseOpenVideoPeer(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<TBrowserRuntimeVideoOffer> {
1369
+ this.requireHuman(lease);
1370
+ return this.runOperation(lease, 'openVideoPeer', 'video-peer', options, async (signal, session) => {
1371
+ lease.videoPeerMayExist = true;
1372
+ const { peerId: _peerId, ...offer } = await session.openVideoPeer(lease.videoPeerId, { signal });
1373
+ return offer;
1374
+ });
1375
+ }
1376
+
1377
+ /** @internal */
1378
+ public async leaseAnswerVideoPeer(lease: ILeaseRecord, negotiationId: string,
1379
+ description: plugins.smartpuppeteer.ILiveVideoDescription, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
1380
+ this.requireHuman(lease);
1381
+ const id = validateBoundedString(negotiationId, 'negotiationId', 1, 128);
1382
+ if (id !== negotiationId) throw new BrowserRuntimeError('INVALID_INPUT');
1383
+ const record = validateExactKeys(description, ['type', 'sdp'], 'video answer');
1384
+ const answer = { type: record.type, sdp: record.sdp } as plugins.smartpuppeteer.ILiveVideoDescription;
1385
+ try { plugins.smartpuppeteer.validateLiveVideoDescription(answer, 'answer'); }
1386
+ catch { throw new BrowserRuntimeError('INVALID_INPUT'); }
1387
+ return this.runOperation(lease, 'answerVideoPeer', 'video-peer', options,
1388
+ async (signal, session) => session.answerVideoPeer(lease.videoPeerId, id, answer, { signal }));
1389
+ }
1390
+
1391
+ /** @internal */
1392
+ public async leaseCloseVideoPeer(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
1393
+ this.requireHuman(lease);
1394
+ return this.runOperation(lease, 'closeVideoPeer', 'video-peer', options, async (_signal, session) => {
1395
+ await session.closeVideoPeer(lease.videoPeerId);
1396
+ lease.videoPeerMayExist = false;
1397
+ });
1398
+ }
1399
+
1400
+ /** @internal */
1401
+ public async leaseGetVideoStatistics(lease: ILeaseRecord, options: IBrowserRuntimeOperationOptions = {}): Promise<plugins.smartpuppeteer.ILiveVideoStatistics> {
1402
+ this.requireHuman(lease);
1403
+ return this.runOperation(lease, 'getVideoStatistics', 'video-peer', options,
1404
+ async (_signal, session) => session.getVideoStatistics(lease.videoPeerId));
1405
+ }
1406
+
1355
1407
  private validatePreferredViewport(
1356
1408
  value: plugins.smartpuppeteer.ILiveBrowserViewport,
1357
1409
  ): plugins.smartpuppeteer.ILiveBrowserViewport {
@@ -1402,7 +1454,7 @@ export class BrowserRuntime {
1402
1454
  this.requireValidLease(lease);
1403
1455
  const slot = lease.slot;
1404
1456
  const subscription = slot.frameSubscriptions.get(lease.leaseId);
1405
- if (!subscription || subscription.closed || subscription.lease !== lease) {
1457
+ if (!subscription || subscription.closed || !subscription.includeFrames || subscription.lease !== lease) {
1406
1458
  throw new BrowserRuntimeError('BUSY');
1407
1459
  }
1408
1460
  let refresh: IFrameRefreshRecord | undefined;
@@ -1599,34 +1651,65 @@ export class BrowserRuntime {
1599
1651
  public async subscribeLeaseEvents(
1600
1652
  lease: ILeaseRecord,
1601
1653
  listener: (event: TBrowserRuntimeEvent) => void,
1654
+ options: IBrowserRuntimeEventSubscriptionOptions = {},
1602
1655
  ): Promise<IBrowserRuntimeFrameSubscription> {
1603
1656
  this.requireHuman(lease);
1604
1657
  if (typeof listener !== 'function') throw new BrowserRuntimeError('INVALID_INPUT');
1658
+ const values = validateExactKeys(options, ['includeFrames'], 'event subscription');
1659
+ if (values.includeFrames !== undefined && typeof values.includeFrames !== 'boolean') {
1660
+ throw new BrowserRuntimeError('INVALID_INPUT');
1661
+ }
1605
1662
  const slot = lease.slot;
1606
1663
  const release = slot.mutex.tryAcquire();
1607
1664
  if (!release) throw new BrowserRuntimeError('BUSY');
1608
- let subscription: IFrameSubscriptionRecord;
1609
1665
  try {
1610
1666
  this.requireValidLease(lease);
1611
- if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
1612
- subscription = {
1613
- lease,
1614
- listener,
1615
- outstanding: new Map(),
1616
- highestSequence: 0,
1617
- closed: false,
1618
- };
1619
- slot.frameSubscriptions.set(lease.leaseId, subscription);
1620
- this.pushToSubscription(subscription, {
1621
- type: 'state',
1622
- state: this.resourceState(slot.session!.getState()),
1667
+ if (values.includeFrames === false) {
1668
+ if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
1669
+ const subscription: IFrameSubscriptionRecord = {
1670
+ lease, listener, includeFrames: false, session: slot.session!,
1671
+ outstanding: new Map(), highestSequence: 0, closed: false,
1672
+ };
1673
+ slot.frameSubscriptions.set(lease.leaseId, subscription);
1674
+ this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(slot.session!.getState()) });
1675
+ return { close: () => this.closeFrameSubscription(subscription) };
1676
+ }
1677
+ } finally { release(); }
1678
+ let subscription: IFrameSubscriptionRecord | undefined;
1679
+ try {
1680
+ return await this.runOperation(lease, 'subscribeEvents', 'frame-stream', {}, async (signal, session) => {
1681
+ if (slot.frameSubscriptions.has(lease.leaseId)) throw new BrowserRuntimeError('BUSY');
1682
+ subscription = {
1683
+ lease, listener, includeFrames: values.includeFrames !== false, session,
1684
+ outstanding: new Map(), highestSequence: 0, closed: false,
1685
+ };
1686
+ slot.frameSubscriptions.set(lease.leaseId, subscription);
1687
+ if (subscription.includeFrames) {
1688
+ lease.frameCaptureUsed = true;
1689
+ await this.reconcileFrameCapture(slot, session, signal);
1690
+ }
1691
+ this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(session.getState()) });
1692
+ const ownedSubscription = subscription;
1693
+ return { close: () => this.closeFrameSubscription(ownedSubscription) };
1623
1694
  });
1624
- } finally {
1625
- release();
1695
+ } catch (error) {
1696
+ if (subscription) {
1697
+ this.detachFrameSubscription(subscription);
1698
+ await this.revokeCapabilityRecord(lease.capability);
1699
+ }
1700
+ throw error;
1626
1701
  }
1627
- return {
1628
- close: async () => this.closeFrameSubscription(subscription),
1629
- };
1702
+ }
1703
+
1704
+ private async reconcileFrameCapture(slot: IResourceSlot, session: ILiveBrowserSessionLike, signal: AbortSignal): Promise<void> {
1705
+ const enabled = [...slot.frameSubscriptions.values()].some((subscription) => (
1706
+ !subscription.closed && !subscription.lease.released && subscription.includeFrames
1707
+ ));
1708
+ if (slot.frameCaptureEnabled === enabled) return;
1709
+ // A failed/aborted CDP transition may have reached the browser; cleanup must reconcile it.
1710
+ slot.frameCaptureEnabled = undefined;
1711
+ await session.setFrameCaptureEnabled(enabled, { signal });
1712
+ slot.frameCaptureEnabled = enabled;
1630
1713
  }
1631
1714
 
1632
1715
  /** @internal */
@@ -1649,23 +1732,30 @@ export class BrowserRuntime {
1649
1732
  lease: ILeaseRecord,
1650
1733
  artifactId: string,
1651
1734
  ): Promise<Uint8Array> {
1652
- this.requireHuman(lease);
1653
1735
  this.requireValidLease(lease);
1654
- return this.requireArtifactStore().read(
1736
+ const bytes = await this.requireArtifactStore().read(
1655
1737
  lease.capability.projectId,
1656
1738
  lease.capability.browserResourceId,
1657
1739
  artifactId,
1740
+ lease.role === 'agent' ? lease.leaseId : undefined,
1658
1741
  );
1742
+ try {
1743
+ this.requireValidLease(lease);
1744
+ return bytes;
1745
+ } catch (errorArg) {
1746
+ bytes.fill(0);
1747
+ throw errorArg;
1748
+ }
1659
1749
  }
1660
1750
 
1661
1751
  /** @internal */
1662
1752
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
1663
- this.requireHuman(lease);
1664
1753
  this.requireValidLease(lease);
1665
1754
  await this.requireArtifactStore().delete(
1666
1755
  lease.capability.projectId,
1667
1756
  lease.capability.browserResourceId,
1668
1757
  artifactId,
1758
+ lease.role === 'agent' ? lease.leaseId : undefined,
1669
1759
  );
1670
1760
  }
1671
1761
 
@@ -1884,6 +1974,9 @@ export class BrowserRuntime {
1884
1974
  controller: new AbortController(),
1885
1975
  authorityGeneration: slot.authorityGeneration,
1886
1976
  released: false,
1977
+ videoPeerId: randomId(24),
1978
+ videoPeerMayExist: false,
1979
+ frameCaptureUsed: false,
1887
1980
  heldKeys: new Map(),
1888
1981
  heldButtons: new Map(),
1889
1982
  };
@@ -1919,8 +2012,9 @@ export class BrowserRuntime {
1919
2012
  forceNoSandbox: false,
1920
2013
  usePipe: true,
1921
2014
  allowEvaluation: true,
2015
+ video: this.options.video,
1922
2016
  screencast: {
1923
- maxOutstandingFrames: this.options.maxOutstandingFrames,
2017
+ enabled: false, maxOutstandingFrames: this.options.maxOutstandingFrames,
1924
2018
  quality: this.options.screencast.quality,
1925
2019
  maxWidth: this.options.screencast.maxWidth,
1926
2020
  maxHeight: this.options.screencast.maxHeight,
@@ -1938,7 +2032,6 @@ export class BrowserRuntime {
1938
2032
  '--disable-quic',
1939
2033
  '--disable-dns-prefetch',
1940
2034
  '--disable-background-networking',
1941
- '--disable-extensions',
1942
2035
  '--webrtc-ip-handling-policy=disable_non_proxied_udp',
1943
2036
  '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
1944
2037
  ],
@@ -1991,6 +2084,7 @@ export class BrowserRuntime {
1991
2084
  }
1992
2085
  if (probeSignal.aborted) throw probeSignal.reason;
1993
2086
  slot.session = session;
2087
+ slot.frameCaptureEnabled = false;
1994
2088
  slot.proxy = proxy;
1995
2089
  slot.profileDirectory = profileDirectory;
1996
2090
  slot.unsubscribeSession = unsubscribe;
@@ -2052,7 +2146,7 @@ export class BrowserRuntime {
2052
2146
  operationOptions: IBrowserRuntimeOperationOptions,
2053
2147
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
2054
2148
  finalize?: () => Promise<void>,
2055
- participantCleanup = false,
2149
+ participantCleanup: 'participant' | 'subscription' | false = false,
2056
2150
  ): Promise<T> {
2057
2151
  if (!operationOptions || typeof operationOptions !== 'object') {
2058
2152
  throw new BrowserRuntimeError('INVALID_INPUT');
@@ -2564,6 +2658,7 @@ export class BrowserRuntime {
2564
2658
  lease.capability.browserResourceId,
2565
2659
  snapshot.mimeType,
2566
2660
  snapshot.data,
2661
+ lease.leaseId,
2567
2662
  );
2568
2663
  return { action: 'screenshot', artifact } satisfies IBrowserScreenshotResult;
2569
2664
  } finally {
@@ -2614,6 +2709,7 @@ export class BrowserRuntime {
2614
2709
 
2615
2710
  private resourceState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
2616
2711
  return {
2712
+ ...(state.videoAcceleration ? { videoAcceleration: { ...state.videoAcceleration } } : {}),
2617
2713
  status: state.status,
2618
2714
  activeTabId: state.activeTabId ? truncateString(state.activeTabId, 128) : null,
2619
2715
  viewportRevision: state.viewportRevision,
@@ -2657,6 +2753,7 @@ export class BrowserRuntime {
2657
2753
  slot.frameRefresh
2658
2754
  || slot.frameSubscriptions.get(lease.leaseId) !== subscription
2659
2755
  || subscription.closed
2756
+ || !subscription.includeFrames
2660
2757
  || subscription.lease !== lease
2661
2758
  || slot.leases.get(lease.leaseId) !== lease
2662
2759
  || slot.session !== session
@@ -2938,6 +3035,7 @@ export class BrowserRuntime {
2938
3035
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
2939
3036
  if (refresh) this.failFrameRefresh(refresh, 'FRAME_TOO_LARGE');
2940
3037
  for (const subscription of slot.frameSubscriptions.values()) {
3038
+ if (!subscription.includeFrames) continue;
2941
3039
  this.pushToSubscription(subscription, {
2942
3040
  type: 'error',
2943
3041
  error: { code: 'FRAME_TOO_LARGE', fatal: false, tabId: acknowledgement.tabId },
@@ -2956,7 +3054,7 @@ export class BrowserRuntime {
2956
3054
  }
2957
3055
  const key = this.frameAcknowledgementKey(acknowledgement);
2958
3056
  for (const subscription of [...slot.frameSubscriptions.values()]) {
2959
- if (subscription.closed) continue;
3057
+ if (subscription.closed || !subscription.includeFrames) continue;
2960
3058
  while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2961
3059
  const oldestKey = subscription.outstanding.keys().next().value!;
2962
3060
  this.trackCleanup(this.retireOutstandingFrame(subscription, oldestKey).then(() => undefined));
@@ -3019,20 +3117,53 @@ export class BrowserRuntime {
3019
3117
  return true;
3020
3118
  } catch {
3021
3119
  // A failed participant transport cannot revoke another participant or kill the producer.
3022
- this.trackCleanup(this.closeFrameSubscription(subscription));
3120
+ this.detachFrameSubscription(subscription);
3023
3121
  this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
3024
3122
  return false;
3025
3123
  }
3026
3124
  }
3027
3125
 
3028
3126
  private async closeFrameSubscription(subscription: IFrameSubscriptionRecord): Promise<void> {
3029
- if (subscription.closed) return;
3127
+ if (subscription.closeComplete) return;
3128
+ if (subscription.closePromise) return subscription.closePromise;
3129
+ this.detachFrameSubscription(subscription, false);
3130
+ const lease = subscription.lease;
3131
+ const slot = lease.slot;
3132
+ const operation = (async () => {
3133
+ if (subscription.includeFrames && slot.session === subscription.session) {
3134
+ if (lease.released) await this.releaseLeaseRecord(lease);
3135
+ else if (this.lifecycleState === 'running' && !slot.permanentlyFenced && !slot.retirementPending
3136
+ && slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined) {
3137
+ await this.runOperation(lease, 'unsubscribeFrames', 'frame-stream', {}, async (signal, session) => {
3138
+ await this.reconcileFrameCapture(slot, session, signal);
3139
+ this.detachFrameSubscription(subscription);
3140
+ subscription.closeComplete = true;
3141
+ }, undefined, 'subscription');
3142
+ }
3143
+ }
3144
+ this.detachFrameSubscription(subscription);
3145
+ subscription.closeComplete = true;
3146
+ })();
3147
+ subscription.closePromise = operation;
3148
+ try { await operation; }
3149
+ catch (error) {
3150
+ // Producer failure may supersede a queued unsubscribe. Its exact incarnation's
3151
+ // confirmed termination completes capture cleanup without issuing another toggle.
3152
+ const failure = slot.frameFailure;
3153
+ if (failure?.session === subscription.session) await failure.promise;
3154
+ if (slot.session === subscription.session || !lease.released) throw error;
3155
+ this.detachFrameSubscription(subscription);
3156
+ subscription.closeComplete = true;
3157
+ } finally { subscription.closePromise = undefined; }
3158
+ }
3159
+
3160
+ private detachFrameSubscription(subscription: IFrameSubscriptionRecord, remove = true): void {
3030
3161
  subscription.closed = true;
3031
3162
  const slot = subscription.lease.slot;
3032
3163
  if (slot.frameRefresh?.subscription === subscription) {
3033
3164
  this.abandonFrameRefresh(slot, slot.frameRefresh);
3034
3165
  }
3035
- if (slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
3166
+ if (remove && slot.frameSubscriptions.get(subscription.lease.leaseId) === subscription) {
3036
3167
  slot.frameSubscriptions.delete(subscription.lease.leaseId);
3037
3168
  }
3038
3169
  for (const frame of subscription.outstanding.values()) {
@@ -3191,10 +3322,10 @@ export class BrowserRuntime {
3191
3322
  this.trackCleanup(operation);
3192
3323
  }
3193
3324
 
3194
- private requireQueuedLease(lease: ILeaseRecord, participantCleanup = false): void {
3325
+ private requireQueuedLease(lease: ILeaseRecord, participantCleanup: 'participant' | 'subscription' | false = false): void {
3195
3326
  if (!participantCleanup) return this.requireValidLease(lease);
3196
3327
  if (
3197
- !lease.released || lease.slot.leases.get(lease.leaseId) !== lease
3328
+ (participantCleanup === 'participant' && !lease.released) || lease.slot.leases.get(lease.leaseId) !== lease
3198
3329
  || lease.capability.lease !== lease || !lease.slot.session
3199
3330
  ) throw new BrowserRuntimeError('CAPABILITY_REVOKED');
3200
3331
  }
@@ -3359,14 +3490,20 @@ export class BrowserRuntime {
3359
3490
  if (
3360
3491
  this.lifecycleState === 'running' && slot.session && !slot.permanentlyFenced && !slot.retirementPending
3361
3492
  && slot.fencingGeneration === undefined && slot.terminationFenceGeneration === undefined
3362
- && (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size)
3493
+ && (lease.preferredViewport || lease.heldKeys.size || lease.heldButtons.size
3494
+ || lease.videoPeerMayExist || lease.frameCaptureUsed)
3363
3495
  ) {
3364
3496
  await this.runOperation(lease, 'releaseParticipant', 'viewport', {},
3365
3497
  async (signal, session) => {
3498
+ if (lease.videoPeerMayExist) {
3499
+ await session.closeVideoPeer(lease.videoPeerId);
3500
+ lease.videoPeerMayExist = false;
3501
+ }
3502
+ if (lease.frameCaptureUsed) await this.reconcileFrameCapture(slot, session, signal);
3366
3503
  await this.releaseParticipantInput(lease, session);
3367
3504
  await this.applySharedViewport(slot, session, signal);
3368
3505
  lease.preferredViewport = undefined;
3369
- }, undefined, true);
3506
+ }, undefined, 'participant');
3370
3507
  }
3371
3508
  if (slot.permanentlyFenced && (slot.session || slot.proxy || slot.profileDirectory)) {
3372
3509
  await this.terminateSlotSession(slot);
@@ -3384,7 +3521,7 @@ export class BrowserRuntime {
3384
3521
 
3385
3522
  private async closeFrameSubscriptionIfLease(lease: ILeaseRecord): Promise<void> {
3386
3523
  const subscription = lease.slot.frameSubscriptions.get(lease.leaseId);
3387
- if (subscription) await this.closeFrameSubscription(subscription);
3524
+ if (subscription) this.detachFrameSubscription(subscription);
3388
3525
  }
3389
3526
 
3390
3527
  private async quiesceSlot(slot: IResourceSlot): Promise<void> {
@@ -4235,8 +4372,26 @@ export class BrowserRuntimeLease {
4235
4372
 
4236
4373
  public subscribeEvents(
4237
4374
  listener: (event: TBrowserRuntimeEvent) => void,
4375
+ options?: IBrowserRuntimeEventSubscriptionOptions,
4238
4376
  ): Promise<IBrowserRuntimeFrameSubscription> {
4239
- return this.runtime.subscribeLeaseEvents(this.record, listener);
4377
+ return this.runtime.subscribeLeaseEvents(this.record, listener, options);
4378
+ }
4379
+
4380
+ public openVideoPeer(options?: IBrowserRuntimeOperationOptions): Promise<TBrowserRuntimeVideoOffer> {
4381
+ return this.runtime.leaseOpenVideoPeer(this.record, options);
4382
+ }
4383
+
4384
+ public answerVideoPeer(negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
4385
+ options?: IBrowserRuntimeOperationOptions): Promise<void> {
4386
+ return this.runtime.leaseAnswerVideoPeer(this.record, negotiationId, description, options);
4387
+ }
4388
+
4389
+ public closeVideoPeer(options?: IBrowserRuntimeOperationOptions): Promise<void> {
4390
+ return this.runtime.leaseCloseVideoPeer(this.record, options);
4391
+ }
4392
+
4393
+ public getVideoStatistics(options?: IBrowserRuntimeOperationOptions): Promise<plugins.smartpuppeteer.ILiveVideoStatistics> {
4394
+ return this.runtime.leaseGetVideoStatistics(this.record, options);
4240
4395
  }
4241
4396
 
4242
4397
  public acknowledgeFrame(
package/ts/index.ts CHANGED
@@ -48,6 +48,8 @@ export type {
48
48
  IBrowserRuntimeOperationOptions,
49
49
  IBrowserRuntimeOptions,
50
50
  IBrowserRuntimeScreencastOptions,
51
+ IBrowserRuntimeEventSubscriptionOptions,
52
+ TBrowserRuntimeVideoOffer,
51
53
  IBrowserResourceKey,
52
54
  IBrowserResourceRegistration,
53
55
  IBrowserRuntimeState,
package/ts/interfaces.ts CHANGED
@@ -133,6 +133,7 @@ export type TBrowserRuntimeOperationIdentity = TReadonlyBrowserCapabilityAuthori
133
133
  export type TBrowserRuntimeOperationClassification =
134
134
  | 'raw-input'
135
135
  | 'frame-stream'
136
+ | 'video-peer'
136
137
  | 'viewport'
137
138
  | 'navigation'
138
139
  | 'tab'
@@ -167,6 +168,12 @@ export interface ILiveBrowserSessionLike {
167
168
  getState(): plugins.smartpuppeteer.ILiveBrowserState;
168
169
  getProcessState(): plugins.smartpuppeteer.ILiveBrowserProcessState;
169
170
  onEvent(listener: plugins.smartpuppeteer.TLiveBrowserEventListener): () => void;
171
+ setFrameCaptureEnabled(enabled: boolean, options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
172
+ openVideoPeer(peerId: string, options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<plugins.smartpuppeteer.ILiveVideoOffer>;
173
+ answerVideoPeer(peerId: string, negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
174
+ options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
175
+ closeVideoPeer(peerId: string): Promise<void>;
176
+ getVideoStatistics(peerId: string): Promise<plugins.smartpuppeteer.ILiveVideoStatistics>;
170
177
  refreshScreencast(
171
178
  options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions,
172
179
  ): Promise<plugins.smartpuppeteer.ILiveBrowserFrameIdentity>;
@@ -291,6 +298,8 @@ export interface IBrowserRuntimeOptions {
291
298
  maxOutstandingFrames?: number;
292
299
  frameAcknowledgementTimeoutMs?: number;
293
300
  screencast?: IBrowserRuntimeScreencastOptions;
301
+ /** Trusted host configuration; defaults to direct ICE and automatic GPU support. */
302
+ video?: plugins.smartpuppeteer.ILiveVideoOptions;
294
303
  egress?: Omit<IBrowserEgressProxyOptions, 'projectId' | 'browserResourceId'>;
295
304
  artifacts?: Omit<IBrowserArtifactStoreOptions, 'rootDirectory'>;
296
305
  }
@@ -306,7 +315,15 @@ export interface IBrowserRuntimeTabState {
306
315
  streaming: boolean;
307
316
  }
308
317
 
318
+ export interface IBrowserRuntimeEventSubscriptionOptions {
319
+ /** Set false for native video viewers. Defaults to true for image-frame consumers. */
320
+ includeFrames?: boolean;
321
+ }
322
+
323
+ export type TBrowserRuntimeVideoOffer = Omit<plugins.smartpuppeteer.ILiveVideoOffer, 'peerId'>;
324
+
309
325
  export interface IBrowserRuntimeState {
326
+ videoAcceleration?: plugins.smartpuppeteer.ILiveVideoAcceleration;
310
327
  status: 'stopped' | 'starting' | 'running' | 'stopping';
311
328
  activeTabId: string | null;
312
329
  viewportRevision: number;
package/readme.hints.md DELETED
@@ -1,60 +0,0 @@
1
- # readme.hints.md
2
-
3
- Durable implementation findings for `@modelprofile.com/browser-runtime`.
4
-
5
- ## Ownership and lifecycle
6
-
7
- - Host-runtime liveness is owned by SmartIPC `NamedMutex` using a namespace bound to the UID and secured runtime-directory device, inode, and path hash. Its permanent private `runtime.mutex` anchor directory and file stay on the runtime directory's trusted local filesystem. Clean native contention is `LOCKED`; backend, anchor, lock, or release uncertainty is `FENCED`.
8
- - Persistent, fsynced `runtime.lock` metadata is a downgrade fence, not the live mutex, and is never removed by normal shutdown. Every start owns one metadata-named generation containing private profile and artifact roots.
9
- - Metadata-generation recovery performs no lock-descriptor scan. It removes only the exact named generation after checking every same-UID process for an absolute `--user-data-dir` equal to or below that profile root; this check is process-generic, not Chromium-specific.
10
- - Process ownership comes from all four `/proc/<pid>/status` `Uid:` values, not `/proc/<pid>` directory ownership. Only an exact same-UID or definite other-UID classification is actionable; mixed, unreadable, malformed, symlinked, wrongly owned/mode, hard-linked, ambiguous, or uncontained ownership state remains fenced.
11
- - An empty process command line triggers one status revalidation. A conclusively departed PID (both status and PID directory missing), definite other UID, or exact same-UID zombie is clear. A still-present PID with missing/unreadable status, ambiguous UID, or malformed/duplicate state remains fenced; exit between command-line and status reads does not block startup.
12
- - Legacy zero-byte locks alone use SmartIPC's consuming kernel probe for other read/write open descriptions. Adoption additionally requires birth, change, and modification timestamps strictly before bounded `/proc/stat` `btime`, plus no same-UID process using the exact or descendant legacy profile root. After an exclusive probe, Runtime reopens and revalidates the exact original inode, ownership, mode, link count, zero size, and timestamps. Probe contention is locked; probe or reopen uncertainty, same-boot, malformed, or ambiguous state remains fenced against the 3.2 close-before-unlink race. `O_PATH` descriptors do not contend.
13
- - A clean 3.2 stopped layout has no lock and may retain private empty top-level profile and artifact roots. The native-mutex winner validates, process-checks, and removes only those roots. Before metadata fsync succeeds, failure cleanup may unlink only the exact lock inode that acquisition just created; preexisting locks are never removed.
14
- - Shutdown attempts artifact and ownership cleanup after peer, revocation, or resource failures, retains retryable local slot state, and blocks restart until that state clears. Automatic ownership-loss cleanup gets one bounded retry; stale active metadata remains a fail-closed recovery fence.
15
- - Controller durable truth is a logical resource with immutable `projectId`, stable random `browserResourceId`, and a revisioned attachment binding. Runtime state is process-local and must be re-registered after every restart.
16
- - A resource slot owns its browser incarnation, transition mutex, generations, exact participant leases, shared bounded FIFO, independent viewer frame windows, authenticated egress proxy, private profile, and idle timer. Joining or releasing a participant does not change the resource arbitration generation.
17
- - `terminateResource()` and idle termination remove only the incarnation. They preserve registration, attachment, capabilities, and artifacts. `retireResource()` permanently fences, revokes, terminates, purges exact-resource artifacts, then unregisters only after all cleanup succeeds. Durable retirement remains Controller truth; Runtime tombstones are bounded and process-local.
18
- - Failed retirement retains a fenced, retryable registration. Unknown profile, lock, or artifact ownership remains fail closed.
19
- - Launch quota admission uses synchronous pending reservations across global and per-project running-resource limits.
20
-
21
- ## Attachment and capability boundary
22
-
23
- - Attachment revision is independent from incarnation and arbitration generations. Equal revisions are idempotent only for an identical binding; lower or conflicting equal revisions fail.
24
- - Any `sessionId: null` binding is detached; revision zero is the initial detached/no-agent-authority state. Attachment transitions fence and revoke only agent authority. Human viewers keep resource access. An agent operation that cannot quiesce still requires terminating that resource's incarnation.
25
- - Agent capabilities require the exact current qualified session. Human issuance validates the attachment current at issuance, but subsequent human operations authorize the exact project/resource/actor/peer independently from agent attachment changes. Flex run channels use trusted pipes; independently authenticated MCP bindings support OpenCode, Codex, and Flex session IDs without impersonating a Flex run channel.
26
- - Capability tokens are returned once. Runtime records retain only SHA-256 digests and use `timingSafeEqual`. Authorization is rechecked after asynchronous host authorization, during lease acquisition, and before and after every operation.
27
- - Capability, audit, and lease identity includes immutable project, resource, attachment authority and revision, actor, peer, role, source, qualified agent session, and Flex scope/channel/run where applicable.
28
- - All participant operations share a resource FIFO with per-lease and aggregate bounds. A released participant's cleanup joins that same FIFO with exact retained lease identity, bypasses user-operation authorization only for resource-owned input release/viewport recomputation, and retains lifecycle cancellation and operation timeout enforcement.
29
- - Human frame refresh enters the shared FIFO, captures the initiating subscription, clears viewer windows, waits for producer acknowledgements, and validates one constant-sized new-generation boundary. A running open tab may refresh while streaming is false. New frames fan out to all participants.
30
- - `beforeOperation` reserves the exact resource and lets the host persist policy/audit through an awaited fail-closed gate. Runtime then atomically revalidates lease, attachment, session, arbitration generation, and incarnation before starting the side effect. Both host hooks carry the same exact operation ID and classification; terminal audit remains best effort and runs after bounded cleanup releases or fences the exact reservation.
31
- - Lease authority snapshots are immutable process-local revalidation tokens. They expose `authorityGeneration` and bind the runtime instance, incarnation, exact `capabilityId`, exact `leaseId`, and attachment, but never replace Controller durable attachment truth.
32
-
33
- - Viewport preferences are per human lease. Effective width, height and DSF use the componentwise minimum; results carry the actual viewport revision, including unchanged-size responses. Departure expands the viewport through serialized resource-owned cleanup and retains its cleanup marker until the resize succeeds, so failed cleanup remains retryable. Input ownership is bounded to 64 keys and 5 buttons per participant, and departure preserves another participant's held input.
34
-
35
- ## Browser confinement
36
-
37
- - Production session options are assembled only inside `BrowserRuntime`: sandbox required, random private per-resource user data directory, one authenticated proxy per running resource, fixed network-reduction arguments, denied downloads/file choosers/permissions, and HTTP(S)-only public navigation.
38
- - `allowEvaluation` exists only for the private confinement probe. No runtime lease, framed message, Flex tool, or MCP tool exposes evaluation.
39
- - The production probe verifies loopback denial, synthetic `.invalid` traversal, and forced WebRTC suppression through the resource's proxy.
40
-
41
- ## Egress and artifacts
42
-
43
- - HTTP absolute-form, WebSocket Upgrade, and CONNECT use one fail-closed target resolver. Every DNS answer must be public unicast and the selected answer is dialed numerically.
44
- - Egress proxies carry immutable `projectId` and `browserResourceId`; credentials are private and one proxy exists per running resource.
45
- - Artifact identity is `(projectId, browserResourceId, artifactId)`. Paths use keyed project and resource digests, and quotas apply per resource, per project, and globally.
46
- - Artifact writes are private, temporary, synced, and atomically renamed. Reads use no-follow handles and verify size plus SHA-256. Human leases can read or delete artifacts only from their exact resource.
47
-
48
- ## Adapter boundary
49
-
50
- - Framed messages contain no identity selectors. The parent binds project, resource, attachment authority/revision, actor, peer, role, source, qualified session, Flex scope, channel, and exact run out of band.
51
- - One qualified session can own multiple resource-specific framed channels. Capability revocation owns server-peer disconnect; peer-initiated release/close revokes only that channel's capability without recursively disconnecting itself.
52
- - Framed request counts and queued write bytes are bounded. Runtime shutdown has a bounded caller-visible cleanup deadline while retaining in-flight cleanup ownership for retry.
53
- - Each viewer has a bounded exact-identity frame map. Each incoming frame starts one bounded producer ACK whose outcome is shared by viewer records. Producer ACK promises have a separate hard bound; viewer lag and eviction cannot re-ACK or block Chrome. Producer failures fence the exact incarnation regardless of which participants subsequently joined it. A failure from an older incarnation cannot fence its replacement.
54
- - Late viewer acknowledgements and oldest-first eviction only retire the local map entry. A throwing listener revokes its own participant. Neither event routes through resource-wide producer failure handling.
55
- - A frame above `maxFrameBytes` advances the resource sequence watermark, is acknowledged once and dropped, and reports nonfatal `FRAME_TOO_LARGE` to viewers. During refresh it also fails the refresh boundary with `FRAME_TOO_LARGE`.
56
- - `screencast` (`quality` 0..100 default 70, `maxWidth`/`maxHeight` 1..4096 each with `maxWidth * maxHeight <= 8_294_400` and defaults 2560/1600, `everyNthFrame` 1..60 default 1, `firstFrameTimeoutMs` 1000..60000 default left to SmartPuppeteer) is validated in the constructor with `validateExactKeys` plus the integer validators and forwarded to `LiveBrowserSession` next to `maxOutstandingFrames`. The ceilings deliberately match `@push.rocks/smartpuppeteer` 2.6's `LiveBrowserSession` validation (`maxViewportPixelArea = 8294400`, `maxTimeoutMs = 60000`); a looser runtime bound would let `new BrowserRuntime()` succeed and every incarnation launch fail instead. Without `quality`/`maxWidth`/`maxHeight`, SmartPuppeteer's defaults (quality 80, unbounded size) produced multi-megabyte frames on device-pixel-ratio 2 viewers.
57
- - Refresh records track bounded producer ACK work independently of viewer windows. Producer failure cleanup is coalesced by exact session; affected operation completion waits for the fence and process cleanup. Closing an initiating subscription abandons its refresh without revoking other viewers.
58
- - Runtime state/error messages are bounded to 2,048 characters. Fatal producer errors revoke every current participant synchronously before captured error delivery; released participants receive no duplicate fatal notification.
59
- - Flex resolves only a capability token, and the provider's run must exactly match its trusted framed client. SmartAgent exposes exactly navigate, snapshot, screenshot, click, fill, and press.
60
- - MCP independent authentication returns the complete expected binding. The binding is retained in server-owned auth context and tool schemas expose no resource, session, revision, or authority selector.