@modelprofile.com/browser-runtime 2.1.1 → 3.0.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.
@@ -19,6 +19,8 @@ import type {
19
19
  IBrowserConfinementProbeContext,
20
20
  IBrowserObservationResult,
21
21
  TBrowserRuntimeAuditEvent,
22
+ TBrowserRuntimeLeaseAuthority,
23
+ TBrowserRuntimeOperationClassification,
22
24
  TBrowserRuntimeOperationIdentity,
23
25
  IBrowserRuntimeFrameSubscription,
24
26
  IBrowserRuntimeOperationOptions,
@@ -45,6 +47,7 @@ import {
45
47
  randomId,
46
48
  truncateString,
47
49
  validateBoundedString,
50
+ validateExactKeys,
48
51
  validateInteger,
49
52
  validateOptionalInteger,
50
53
  waitBounded,
@@ -73,14 +76,17 @@ interface INormalizedRuntimeOptions {
73
76
  authorizationTimeoutMs: number;
74
77
  beforeOperationTimeoutMs: number;
75
78
  maxTabsPerResource: number;
79
+ maxQueuedOperationsPerLease: number;
76
80
  operationTimeoutMs: number;
77
81
  quiescenceTimeoutMs: number;
78
82
  terminationGraceMs: number;
79
83
  terminationForceMs: number;
84
+ cleanupTimeoutMs: number;
80
85
  auditTimeoutMs: number;
81
86
  confinementProbeTimeoutMs: number;
82
87
  idleTerminationMs: number;
83
88
  maxFrameBytes: number;
89
+ maxOutstandingFrames: number;
84
90
  frameAcknowledgementTimeoutMs: number;
85
91
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
86
92
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
@@ -112,6 +118,7 @@ interface ILeaseRecord {
112
118
  role: TBrowserActorRole;
113
119
  slot: IResourceSlot;
114
120
  controller: AbortController;
121
+ authorityGeneration: number;
115
122
  released: boolean;
116
123
  releasePromise?: Promise<void>;
117
124
  releaseGeneration?: number;
@@ -129,13 +136,39 @@ interface IOperationRecord {
129
136
  promise: Promise<void>;
130
137
  }
131
138
 
139
+ interface IQueuedOperationRecord {
140
+ operationId: string;
141
+ lease: ILeaseRecord;
142
+ action: string;
143
+ classification: TBrowserRuntimeOperationClassification;
144
+ timeoutMs: number;
145
+ externalSignal?: AbortSignal;
146
+ onOperationStarted?: (operationId: string) => void;
147
+ signal: AbortSignal;
148
+ execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<unknown>;
149
+ resolve(value: unknown): void;
150
+ reject(error: unknown): void;
151
+ state: 'queued' | 'starting' | 'active' | 'settled';
152
+ onQueuedAbort?: () => void;
153
+ }
154
+
155
+ interface IOutstandingFrameRecord {
156
+ acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
157
+ timer: ReturnType<typeof setTimeout>;
158
+ session: ILiveBrowserSessionLike;
159
+ incarnationGeneration: number;
160
+ }
161
+
162
+ type TFrameAcknowledgementOutcome =
163
+ | { status: 'fulfilled'; accepted: boolean }
164
+ | { status: 'rejected' }
165
+ | { status: 'timedOut' };
166
+
132
167
  interface IFrameSubscriptionRecord {
133
168
  lease: ILeaseRecord;
134
169
  listener: (event: TBrowserRuntimeEvent) => void;
135
- outstanding?: {
136
- acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
137
- timer: ReturnType<typeof setTimeout>;
138
- };
170
+ outstanding: Map<string, IOutstandingFrameRecord>;
171
+ highestSequence: number;
139
172
  closed: boolean;
140
173
  }
141
174
 
@@ -147,6 +180,7 @@ interface IResourceSlot {
147
180
  mutex: TransitionMutex;
148
181
  arbitrationGeneration: number;
149
182
  incarnationGeneration: number;
183
+ authorityGeneration: number;
150
184
  fencingGeneration?: number;
151
185
  permanentlyFenced: boolean;
152
186
  retirementPending: boolean;
@@ -158,6 +192,8 @@ interface IResourceSlot {
158
192
  unsubscribeSession?: () => void;
159
193
  lease?: ILeaseRecord;
160
194
  operation?: IOperationRecord;
195
+ operationQueue: IQueuedOperationRecord[];
196
+ operationSchedulerRunning: boolean;
161
197
  frameSubscription?: IFrameSubscriptionRecord;
162
198
  idleTimer?: ReturnType<typeof setTimeout>;
163
199
  lifecycleTail: Promise<void>;
@@ -182,6 +218,7 @@ export class BrowserRuntime {
182
218
  private readonly artifactRoot: string;
183
219
  private readonly lockPath: string;
184
220
  private readonly artifactStore: BrowserArtifactStore;
221
+ private readonly runtimeAuthorityId = randomId(18);
185
222
  private readonly slots = new Map<string, IResourceSlot>();
186
223
  private readonly retiredResourceIds = new Uint8Array(64 * 1024);
187
224
  private readonly retiredResourceKeys = new Uint8Array(64 * 1024);
@@ -195,6 +232,7 @@ export class BrowserRuntime {
195
232
  private lockHandleClosed = false;
196
233
  private startPromise?: Promise<void>;
197
234
  private stopPromise?: Promise<void>;
235
+ private stopCleanupPromise?: Promise<void>;
198
236
  private lifecycleState: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
199
237
  private lifecycleEpoch = 0;
200
238
  private lifecycleController = new AbortController();
@@ -331,6 +369,13 @@ export class BrowserRuntime {
331
369
  256,
332
370
  32,
333
371
  ),
372
+ maxQueuedOperationsPerLease: validateOptionalInteger(
373
+ options.maxQueuedOperationsPerLease,
374
+ 'maxQueuedOperationsPerLease',
375
+ 1,
376
+ 1024,
377
+ 128,
378
+ ),
334
379
  operationTimeoutMs: validateOptionalInteger(
335
380
  options.operationTimeoutMs,
336
381
  'operationTimeoutMs',
@@ -359,6 +404,13 @@ export class BrowserRuntime {
359
404
  60_000,
360
405
  5000,
361
406
  ),
407
+ cleanupTimeoutMs: validateOptionalInteger(
408
+ options.cleanupTimeoutMs,
409
+ 'cleanupTimeoutMs',
410
+ 100,
411
+ 120_000,
412
+ 30_000,
413
+ ),
362
414
  auditTimeoutMs: validateOptionalInteger(
363
415
  options.auditTimeoutMs,
364
416
  'auditTimeoutMs',
@@ -387,6 +439,13 @@ export class BrowserRuntime {
387
439
  16 * 1024 * 1024,
388
440
  4 * 1024 * 1024,
389
441
  ),
442
+ maxOutstandingFrames: validateOptionalInteger(
443
+ options.maxOutstandingFrames,
444
+ 'maxOutstandingFrames',
445
+ 1,
446
+ 32,
447
+ 4,
448
+ ),
390
449
  frameAcknowledgementTimeoutMs: validateOptionalInteger(
391
450
  options.frameAcknowledgementTimeoutMs,
392
451
  'frameAcknowledgementTimeoutMs',
@@ -413,7 +472,7 @@ export class BrowserRuntime {
413
472
  public start(): Promise<void> {
414
473
  if (this.lifecycleState === 'running') return Promise.resolve();
415
474
  if (this.startPromise) return this.startPromise;
416
- if (this.stopPromise) return this.stopPromise.then(() => this.start());
475
+ if (this.stopCleanupPromise) return this.stopCleanupPromise.then(() => this.start());
417
476
  this.lifecycleState = 'starting';
418
477
  const epoch = ++this.lifecycleEpoch;
419
478
  this.lifecycleController = new AbortController();
@@ -435,17 +494,38 @@ export class BrowserRuntime {
435
494
 
436
495
  public stop(): Promise<void> {
437
496
  if (this.stopPromise) return this.stopPromise;
438
- if (this.lifecycleState === 'stopped' && !this.lockHandle && !this.startPromise) {
497
+ if (
498
+ this.lifecycleState === 'stopped'
499
+ && !this.lockHandle
500
+ && !this.startPromise
501
+ && !this.stopCleanupPromise
502
+ ) {
439
503
  return Promise.resolve();
440
504
  }
441
- this.lifecycleState = 'stopping';
442
- this.lifecycleEpoch += 1;
443
- this.lifecycleController.abort(new BrowserRuntimeError('ABORTED'));
444
- const startup = this.startPromise;
505
+ if (!this.stopCleanupPromise) {
506
+ this.lifecycleState = 'stopping';
507
+ this.lifecycleEpoch += 1;
508
+ this.lifecycleController.abort(new BrowserRuntimeError('ABORTED'));
509
+ const startup = this.startPromise;
510
+ const cleanup = (async () => {
511
+ await startup?.catch(() => undefined);
512
+ await this.stopInternal();
513
+ this.lifecycleState = 'stopped';
514
+ })();
515
+ this.stopCleanupPromise = cleanup;
516
+ void cleanup.then(
517
+ () => {
518
+ if (this.stopCleanupPromise === cleanup) this.stopCleanupPromise = undefined;
519
+ },
520
+ () => {
521
+ if (this.stopCleanupPromise === cleanup) this.stopCleanupPromise = undefined;
522
+ },
523
+ );
524
+ }
525
+ const cleanup = this.stopCleanupPromise;
445
526
  this.stopPromise = (async () => {
446
- await startup?.catch(() => undefined);
447
- await this.stopInternal();
448
- this.lifecycleState = 'stopped';
527
+ const result = await waitBounded(cleanup, this.options.cleanupTimeoutMs);
528
+ if (!result.settled) throw new BrowserRuntimeError('TIMEOUT');
449
529
  })().finally(() => {
450
530
  this.stopPromise = undefined;
451
531
  });
@@ -501,9 +581,12 @@ export class BrowserRuntime {
501
581
  mutex: new TransitionMutex(),
502
582
  arbitrationGeneration: 0,
503
583
  incarnationGeneration: 0,
584
+ authorityGeneration: 0,
504
585
  permanentlyFenced: false,
505
586
  retirementPending: false,
506
587
  attachmentFenceCount: 0,
588
+ operationQueue: [],
589
+ operationSchedulerRunning: false,
507
590
  lifecycleTail: Promise.resolve(),
508
591
  lifecycleOperationCount: 0,
509
592
  terminationRequestGeneration: 0,
@@ -638,16 +721,22 @@ export class BrowserRuntime {
638
721
  const channelId = requestArg.channelId === undefined
639
722
  ? undefined
640
723
  : this.validateIdentifier(requestArg.channelId, 'channelId');
724
+ const runId = requestArg.runId === undefined
725
+ ? undefined
726
+ : this.validateIdentifier(requestArg.runId, 'runId');
641
727
  const sessionId = requestArg.sessionId === undefined
642
728
  ? undefined
643
729
  : this.validateQualifiedSessionId(requestArg.sessionId);
644
730
  if (
645
731
  (role === 'agent' && !sessionId)
646
732
  || (role === 'human' && sessionId !== undefined)
647
- || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
733
+ || (source === 'flex' && (!scopeId || !channelId || !runId
734
+ || sessionId?.harnessId !== 'flex'))
648
735
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
736
+ || runId !== undefined
649
737
  || sessionId?.harnessId !== 'opencode'))
650
- || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
738
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined
739
+ || runId !== undefined))
651
740
  ) throw new BrowserRuntimeError('INVALID_INPUT');
652
741
  const authority = this.validateExpectedCapabilityBinding({
653
742
  projectId,
@@ -660,6 +749,7 @@ export class BrowserRuntime {
660
749
  source,
661
750
  ...(scopeId ? { scopeId } : {}),
662
751
  ...(channelId ? { channelId } : {}),
752
+ ...(runId ? { runId } : {}),
663
753
  ...(sessionId ? { sessionId } : {}),
664
754
  } as TBrowserCapabilityAuthorizationRequest);
665
755
  this.assertCapabilityBinding(slot, {
@@ -925,6 +1015,34 @@ export class BrowserRuntime {
925
1015
  return this.resourceState(session.getState());
926
1016
  }
927
1017
 
1018
+ /** @internal */
1019
+ public getLeaseAuthority(lease: ILeaseRecord): TBrowserRuntimeLeaseAuthority {
1020
+ this.requireRunning();
1021
+ this.requireValidLease(lease);
1022
+ return this.createLeaseAuthority(lease);
1023
+ }
1024
+
1025
+ /** @internal */
1026
+ public isLeaseAuthorityCurrent(
1027
+ lease: ILeaseRecord,
1028
+ authorityArg: TBrowserRuntimeLeaseAuthority,
1029
+ ): boolean {
1030
+ const authority = this.validateLeaseAuthority(authorityArg);
1031
+ try {
1032
+ this.requireRunning();
1033
+ this.requireValidLease(lease);
1034
+ this.assertSlotAvailable(lease.slot);
1035
+ } catch {
1036
+ return false;
1037
+ }
1038
+ return authority.runtimeAuthorityId === this.runtimeAuthorityId
1039
+ && authority.authorityGeneration === lease.authorityGeneration
1040
+ && authority.incarnationGeneration === lease.slot.incarnationGeneration
1041
+ && authority.capabilityId === lease.capability.capabilityId
1042
+ && authority.leaseId === lease.leaseId
1043
+ && this.capabilityIdentitiesEqual(authority, this.describeCapabilityIdentity(lease.capability));
1044
+ }
1045
+
928
1046
  /** @internal */
929
1047
  public async executeLeaseAgentAction(
930
1048
  lease: ILeaseRecord,
@@ -933,9 +1051,13 @@ export class BrowserRuntime {
933
1051
  ): Promise<TBrowserAgentActionResult> {
934
1052
  if (lease.role !== 'agent') throw new BrowserRuntimeError('CAPABILITY_INVALID');
935
1053
  const action = validateAgentAction(actionArg);
936
- return this.runOperation(lease, action.action, operationOptions, async (signal, session) => (
937
- this.executeAgentActionInternal(lease, session, action, signal)
938
- ));
1054
+ return this.runOperation(
1055
+ lease,
1056
+ action.action,
1057
+ this.classifyAgentAction(action),
1058
+ operationOptions,
1059
+ async (signal, session) => this.executeAgentActionInternal(lease, session, action, signal),
1060
+ );
939
1061
  }
940
1062
 
941
1063
  /** @internal */
@@ -945,7 +1067,7 @@ export class BrowserRuntime {
945
1067
  operationOptions: IBrowserRuntimeOperationOptions = {},
946
1068
  ): Promise<IBrowserRuntimeState> {
947
1069
  this.requireHuman(lease);
948
- return this.runOperation(lease, 'createTab', operationOptions, async (signal, session) => {
1070
+ return this.runOperation(lease, 'createTab', 'tab', operationOptions, async (signal, session) => {
949
1071
  if (session.getState().tabs.length >= this.options.maxTabsPerResource) {
950
1072
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
951
1073
  }
@@ -962,7 +1084,7 @@ export class BrowserRuntime {
962
1084
  ): Promise<IBrowserRuntimeState> {
963
1085
  this.requireHuman(lease);
964
1086
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
965
- return this.runOperation(lease, 'activateTab', operationOptions, async (signal, session) => {
1087
+ return this.runOperation(lease, 'activateTab', 'tab', operationOptions, async (signal, session) => {
966
1088
  await session.activateTab(tabId, { signal });
967
1089
  return this.resourceState(session.getState());
968
1090
  });
@@ -976,7 +1098,7 @@ export class BrowserRuntime {
976
1098
  ): Promise<IBrowserRuntimeState> {
977
1099
  this.requireHuman(lease);
978
1100
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
979
- return this.runOperation(lease, 'closeTab', operationOptions, async (signal, session) => {
1101
+ return this.runOperation(lease, 'closeTab', 'tab', operationOptions, async (signal, session) => {
980
1102
  await session.closeTab(tabId, { signal });
981
1103
  return this.resourceState(session.getState());
982
1104
  });
@@ -990,7 +1112,7 @@ export class BrowserRuntime {
990
1112
  operationOptions: IBrowserRuntimeOperationOptions = {},
991
1113
  ): Promise<IBrowserRuntimeState> {
992
1114
  this.requireHuman(lease);
993
- return this.runOperation(lease, action, operationOptions, async (signal, session) => {
1115
+ return this.runOperation(lease, action, 'navigation', operationOptions, async (signal, session) => {
994
1116
  await session[action](options, { signal });
995
1117
  return this.resourceState(session.getState());
996
1118
  });
@@ -1004,9 +1126,13 @@ export class BrowserRuntime {
1004
1126
  ): Promise<TBrowserAgentActionResult> {
1005
1127
  this.requireHuman(lease);
1006
1128
  const action = validateAgentAction(actionArg);
1007
- return this.runOperation(lease, action.action, operationOptions, async (signal, session) => (
1008
- this.executeAgentActionInternal(lease, session, action, signal)
1009
- ));
1129
+ return this.runOperation(
1130
+ lease,
1131
+ action.action,
1132
+ this.classifyAgentAction(action),
1133
+ operationOptions,
1134
+ async (signal, session) => this.executeAgentActionInternal(lease, session, action, signal),
1135
+ );
1010
1136
  }
1011
1137
 
1012
1138
  /** @internal */
@@ -1016,9 +1142,15 @@ export class BrowserRuntime {
1016
1142
  operationOptions: IBrowserRuntimeOperationOptions = {},
1017
1143
  ): Promise<void> {
1018
1144
  this.requireHuman(lease);
1019
- await this.runOperation(lease, 'setViewport', operationOptions, async (signal, session) => {
1145
+ await this.runOperation(
1146
+ lease,
1147
+ 'setViewport',
1148
+ 'viewport',
1149
+ operationOptions,
1150
+ async (signal, session) => {
1020
1151
  await session.setViewport(viewport, { signal });
1021
- });
1152
+ },
1153
+ );
1022
1154
  }
1023
1155
 
1024
1156
  /** @internal */
@@ -1033,7 +1165,7 @@ export class BrowserRuntime {
1033
1165
  operationOptions: IBrowserRuntimeOperationOptions = {},
1034
1166
  ): Promise<void> {
1035
1167
  this.requireHuman(lease);
1036
- await this.runOperation(lease, action, operationOptions, async (_signal, session) => {
1168
+ await this.runOperation(lease, action, 'raw-input', operationOptions, async (_signal, session) => {
1037
1169
  if (action === 'dispatchMouse') {
1038
1170
  await session.dispatchMouse(input as plugins.smartpuppeteer.ILiveBrowserMouseInput);
1039
1171
  } else if (action === 'dispatchWheel') {
@@ -1059,7 +1191,13 @@ export class BrowserRuntime {
1059
1191
  try {
1060
1192
  this.requireValidLease(lease);
1061
1193
  if (slot.frameSubscription) throw new BrowserRuntimeError('BUSY');
1062
- subscription = { lease, listener, closed: false };
1194
+ subscription = {
1195
+ lease,
1196
+ listener,
1197
+ outstanding: new Map(),
1198
+ highestSequence: 0,
1199
+ closed: false,
1200
+ };
1063
1201
  slot.frameSubscription = subscription;
1064
1202
  this.pushToSubscription(subscription, {
1065
1203
  type: 'state',
@@ -1076,46 +1214,28 @@ export class BrowserRuntime {
1076
1214
  /** @internal */
1077
1215
  public async acknowledgeLeaseFrame(
1078
1216
  lease: ILeaseRecord,
1079
- acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1217
+ acknowledgementArg: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1080
1218
  ): Promise<boolean> {
1081
1219
  this.requireHuman(lease);
1082
- if (!acknowledgement || typeof acknowledgement !== 'object') {
1083
- throw new BrowserRuntimeError('INVALID_INPUT');
1084
- }
1085
- validateBoundedString(acknowledgement.tabId, 'tabId', 1, 128);
1086
- validateInteger(acknowledgement.sequence, 'sequence', 0, Number.MAX_SAFE_INTEGER);
1087
- validateInteger(acknowledgement.generation, 'generation', 0, Number.MAX_SAFE_INTEGER);
1088
- validateInteger(
1089
- acknowledgement.viewportRevision,
1090
- 'viewportRevision',
1091
- 0,
1092
- Number.MAX_SAFE_INTEGER,
1093
- );
1220
+ const acknowledgement = this.validateFrameAcknowledgement(acknowledgementArg, true);
1221
+ const key = this.frameAcknowledgementKey(acknowledgement);
1094
1222
  const slot = lease.slot;
1095
1223
  const subscription = slot.frameSubscription;
1096
1224
  this.requireValidLease(lease);
1097
- if (!subscription || subscription.lease !== lease || !subscription.outstanding) return false;
1098
- const expected = subscription.outstanding.acknowledgement;
1099
- if (
1100
- expected.tabId !== acknowledgement.tabId
1101
- || expected.sequence !== acknowledgement.sequence
1102
- || expected.generation !== acknowledgement.generation
1103
- || expected.viewportRevision !== acknowledgement.viewportRevision
1104
- ) return false;
1105
- clearTimeout(subscription.outstanding.timer);
1106
- subscription.outstanding = undefined;
1107
- const session = slot.session!;
1108
- const incarnationGeneration = slot.incarnationGeneration;
1109
- const operation = session.acknowledgeFrame(acknowledgement);
1110
- const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
1111
- .catch(() => ({ settled: true as const, value: { accepted: false } }));
1112
- if (!result.settled || !result.value.accepted) {
1113
- operation.catch(() => undefined);
1114
- await this.options.beforeFrameFailureTermination?.();
1115
- await this.handleFrameFailure(slot, session, incarnationGeneration, lease);
1116
- return false;
1117
- }
1118
- return true;
1225
+ if (!subscription || subscription.lease !== lease) return false;
1226
+ const outstanding = subscription.outstanding.get(key);
1227
+ if (!outstanding) return false;
1228
+ subscription.outstanding.delete(key);
1229
+ clearTimeout(outstanding.timer);
1230
+ const { session, incarnationGeneration } = outstanding;
1231
+ const outcome = await this.waitForFrameAcknowledgement(
1232
+ session,
1233
+ outstanding.acknowledgement,
1234
+ );
1235
+ if (outcome.status === 'fulfilled') return outcome.accepted;
1236
+ await this.options.beforeFrameFailureTermination?.();
1237
+ await this.handleFrameFailure(slot, session, incarnationGeneration, subscription.lease);
1238
+ return false;
1119
1239
  }
1120
1240
 
1121
1241
  /** @internal */
@@ -1229,7 +1349,9 @@ export class BrowserRuntime {
1229
1349
  }
1230
1350
 
1231
1351
  private requireRunning(): void {
1232
- if (this.lifecycleState !== 'running') throw new BrowserRuntimeError('NOT_RUNNING');
1352
+ if (this.lifecycleState !== 'running' || this.lifecycleController.signal.aborted) {
1353
+ throw new BrowserRuntimeError('NOT_RUNNING');
1354
+ }
1233
1355
  }
1234
1356
 
1235
1357
  private async acquireAgentLease(
@@ -1347,6 +1469,11 @@ export class BrowserRuntime {
1347
1469
  clearTimeout(slot.idleTimer);
1348
1470
  slot.idleTimer = undefined;
1349
1471
  }
1472
+ if (slot.authorityGeneration >= Number.MAX_SAFE_INTEGER) {
1473
+ slot.permanentlyFenced = true;
1474
+ throw new BrowserRuntimeError('FENCED');
1475
+ }
1476
+ slot.authorityGeneration += 1;
1350
1477
  const lease: ILeaseRecord = {
1351
1478
  leaseId: randomId(18),
1352
1479
  capability,
@@ -1355,6 +1482,7 @@ export class BrowserRuntime {
1355
1482
  role: capability.role,
1356
1483
  slot,
1357
1484
  controller: new AbortController(),
1485
+ authorityGeneration: slot.authorityGeneration,
1358
1486
  released: false,
1359
1487
  };
1360
1488
  slot.lease = lease;
@@ -1387,6 +1515,9 @@ export class BrowserRuntime {
1387
1515
  forceNoSandbox: false,
1388
1516
  usePipe: true,
1389
1517
  allowEvaluation: true,
1518
+ screencast: {
1519
+ maxOutstandingFrames: this.options.maxOutstandingFrames,
1520
+ },
1390
1521
  launchOptions: {
1391
1522
  headless: true,
1392
1523
  userDataDir: profileDirectory,
@@ -1503,9 +1634,10 @@ export class BrowserRuntime {
1503
1634
  }
1504
1635
  }
1505
1636
 
1506
- private async runOperation<T>(
1637
+ private runOperation<T>(
1507
1638
  lease: ILeaseRecord,
1508
1639
  action: string,
1640
+ classification: TBrowserRuntimeOperationClassification,
1509
1641
  operationOptions: IBrowserRuntimeOperationOptions,
1510
1642
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
1511
1643
  ): Promise<T> {
@@ -1522,10 +1654,94 @@ export class BrowserRuntime {
1522
1654
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1523
1655
  if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1524
1656
  const slot = lease.slot;
1525
- const release = slot.mutex.tryAcquire();
1526
- if (!release) throw new BrowserRuntimeError('BUSY');
1657
+ this.requireValidLease(lease);
1658
+ this.assertSlotAvailable(slot);
1659
+ if (slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease) {
1660
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1661
+ }
1662
+ const timeoutMs = operationOptions.timeoutMs === undefined
1663
+ ? this.options.operationTimeoutMs
1664
+ : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1665
+ const signal = AbortSignal.any([
1666
+ lease.controller.signal,
1667
+ this.lifecycleController.signal,
1668
+ operationOptions.signal ?? new AbortController().signal,
1669
+ ]);
1670
+ return new Promise<T>((resolve, reject) => {
1671
+ const queued: IQueuedOperationRecord = {
1672
+ operationId: randomId(18),
1673
+ lease,
1674
+ action,
1675
+ classification,
1676
+ timeoutMs,
1677
+ externalSignal: operationOptions.signal,
1678
+ onOperationStarted: operationOptions.onOperationStarted,
1679
+ signal,
1680
+ execute: execute as IQueuedOperationRecord['execute'],
1681
+ resolve: (value) => resolve(value as T),
1682
+ reject,
1683
+ state: 'queued',
1684
+ };
1685
+ const onQueuedAbort = (): void => {
1686
+ if (queued.state !== 'queued' && queued.state !== 'starting') return;
1687
+ if (queued.state === 'queued') {
1688
+ const index = slot.operationQueue.indexOf(queued);
1689
+ if (index >= 0) slot.operationQueue.splice(index, 1);
1690
+ }
1691
+ queued.state = 'settled';
1692
+ signal.removeEventListener('abort', onQueuedAbort);
1693
+ reject(this.queuedOperationAbortError(queued));
1694
+ };
1695
+ queued.onQueuedAbort = onQueuedAbort;
1696
+ signal.addEventListener('abort', onQueuedAbort, { once: true });
1697
+ if (signal.aborted) {
1698
+ onQueuedAbort();
1699
+ return;
1700
+ }
1701
+ slot.operationQueue.push(queued);
1702
+ this.drainOperationQueue(slot);
1703
+ });
1704
+ }
1705
+
1706
+ private drainOperationQueue(slot: IResourceSlot): void {
1707
+ if (slot.operationSchedulerRunning) return;
1708
+ slot.operationSchedulerRunning = true;
1709
+ const scheduler = (async () => {
1710
+ while (slot.operationQueue.length > 0) {
1711
+ const queued = slot.operationQueue.shift()!;
1712
+ if (queued.state === 'settled') continue;
1713
+ queued.state = 'starting';
1714
+ try {
1715
+ const result = await this.executeQueuedOperation(queued);
1716
+ if ((queued as IQueuedOperationRecord).state !== 'settled') {
1717
+ queued.state = 'settled';
1718
+ queued.resolve(result);
1719
+ }
1720
+ } catch (error) {
1721
+ if (queued.state !== 'settled') {
1722
+ queued.state = 'settled';
1723
+ queued.reject(error);
1724
+ }
1725
+ } finally {
1726
+ if (queued.onQueuedAbort) {
1727
+ queued.signal.removeEventListener('abort', queued.onQueuedAbort);
1728
+ queued.onQueuedAbort = undefined;
1729
+ }
1730
+ }
1731
+ }
1732
+ })().finally(() => {
1733
+ slot.operationSchedulerRunning = false;
1734
+ if (slot.operationQueue.length > 0) this.drainOperationQueue(slot);
1735
+ });
1736
+ void scheduler.catch(() => undefined);
1737
+ }
1738
+
1739
+ private async executeQueuedOperation(queued: IQueuedOperationRecord): Promise<unknown> {
1740
+ const { lease, action, classification } = queued;
1741
+ const slot = lease.slot;
1742
+ const release = await slot.mutex.acquire();
1527
1743
  let operation: IOperationRecord;
1528
- let executionPromise: Promise<T> | undefined;
1744
+ let executionPromise: Promise<unknown> | undefined;
1529
1745
  let combinedSignal: AbortSignal;
1530
1746
  let timeout: ReturnType<typeof setTimeout>;
1531
1747
  let session: ILiveBrowserSessionLike;
@@ -1534,35 +1750,34 @@ export class BrowserRuntime {
1534
1750
  let externalAbort = false;
1535
1751
  let onExternalAbort: (() => void) | undefined;
1536
1752
  let resolvePreflight!: () => void;
1537
- const startedAt = Date.now();
1753
+ let startedAt: number;
1538
1754
  try {
1755
+ if (queued.state === 'settled') return undefined;
1756
+ queued.signal.throwIfAborted();
1539
1757
  this.requireValidLease(lease);
1540
1758
  this.assertSlotAvailable(slot);
1541
1759
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1542
1760
  session = slot.session!;
1543
1761
  generation = slot.arbitrationGeneration;
1544
1762
  incarnationGeneration = slot.incarnationGeneration;
1545
- const timeoutMs = operationOptions.timeoutMs === undefined
1546
- ? this.options.operationTimeoutMs
1547
- : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1763
+ startedAt = Date.now();
1548
1764
  const controller = new AbortController();
1549
1765
  const timeoutController = new AbortController();
1550
1766
  timeout = setTimeout(() => {
1551
1767
  timeoutController.abort(new BrowserRuntimeError('TIMEOUT'));
1552
- }, timeoutMs);
1768
+ }, queued.timeoutMs);
1553
1769
  timeout.unref();
1554
- const externalSignal = operationOptions.signal;
1770
+ const externalSignal = queued.externalSignal;
1555
1771
  onExternalAbort = () => { externalAbort = true; };
1556
1772
  externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
1557
1773
  if (externalSignal?.aborted) externalAbort = true;
1558
1774
  combinedSignal = AbortSignal.any([
1559
1775
  controller.signal,
1560
- lease.controller.signal,
1561
- externalSignal ?? new AbortController().signal,
1776
+ queued.signal,
1562
1777
  timeoutController.signal,
1563
1778
  ]);
1564
1779
  operation = {
1565
- operationId: randomId(18),
1780
+ operationId: queued.operationId,
1566
1781
  lease,
1567
1782
  controller,
1568
1783
  action,
@@ -1572,8 +1787,13 @@ export class BrowserRuntime {
1572
1787
  promise: new Promise<void>((resolve) => { resolvePreflight = resolve; }),
1573
1788
  };
1574
1789
  slot.operation = operation;
1790
+ queued.state = 'active';
1791
+ if (queued.onQueuedAbort) {
1792
+ queued.signal.removeEventListener('abort', queued.onQueuedAbort);
1793
+ queued.onQueuedAbort = undefined;
1794
+ }
1575
1795
  try {
1576
- operationOptions.onOperationStarted?.(operation.operationId);
1796
+ queued.onOperationStarted?.(operation.operationId);
1577
1797
  } catch {
1578
1798
  controller.abort(new BrowserRuntimeError('ABORTED'));
1579
1799
  }
@@ -1581,9 +1801,15 @@ export class BrowserRuntime {
1581
1801
  release();
1582
1802
  }
1583
1803
 
1584
- const operationIdentity = this.createOperationIdentity(lease, operation, action, startedAt);
1804
+ const operationIdentity = this.createOperationIdentity(
1805
+ lease,
1806
+ operation,
1807
+ action,
1808
+ classification,
1809
+ startedAt,
1810
+ );
1585
1811
 
1586
- let result: T | undefined;
1812
+ let result: unknown;
1587
1813
  let failed = false;
1588
1814
  let failure: unknown;
1589
1815
  let executionStarted = false;
@@ -1602,7 +1828,7 @@ export class BrowserRuntime {
1602
1828
  || slot.incarnationGeneration !== incarnationGeneration
1603
1829
  ) throw new BrowserRuntimeError('ABORTED');
1604
1830
  executionStarted = true;
1605
- executionPromise = execute(combinedSignal, session);
1831
+ executionPromise = queued.execute(combinedSignal, session);
1606
1832
  operation.promise = executionPromise.then(() => undefined, () => undefined);
1607
1833
  } finally {
1608
1834
  executionRelease();
@@ -1628,7 +1854,7 @@ export class BrowserRuntime {
1628
1854
  failure = this.normalizeOperationError(error, combinedSignal!, externalAbort);
1629
1855
  } finally {
1630
1856
  clearTimeout(timeout!);
1631
- operationOptions.signal?.removeEventListener('abort', onExternalAbort!);
1857
+ queued.externalSignal?.removeEventListener('abort', onExternalAbort!);
1632
1858
  resolvePreflight();
1633
1859
  const cleanupSlot = async (): Promise<void> => {
1634
1860
  const cleanupRelease = await slot.mutex.acquire();
@@ -1658,7 +1884,14 @@ export class BrowserRuntime {
1658
1884
  }));
1659
1885
  }
1660
1886
  if (failed) throw failure;
1661
- return result as T;
1887
+ return result;
1888
+ }
1889
+
1890
+ private queuedOperationAbortError(queued: IQueuedOperationRecord): BrowserRuntimeError {
1891
+ if (queued.externalSignal?.aborted) return new BrowserRuntimeError('ABORTED');
1892
+ return queued.signal.reason instanceof BrowserRuntimeError
1893
+ ? queued.signal.reason
1894
+ : new BrowserRuntimeError('ABORTED');
1662
1895
  }
1663
1896
 
1664
1897
  private normalizeOperationError(
@@ -1733,6 +1966,7 @@ export class BrowserRuntime {
1733
1966
  leaseArg: ILeaseRecord,
1734
1967
  operationArg: IOperationRecord,
1735
1968
  actionArg: string,
1969
+ classificationArg: TBrowserRuntimeOperationClassification,
1736
1970
  startedAtArg: number,
1737
1971
  ): TBrowserRuntimeOperationIdentity {
1738
1972
  const identity = this.describeCapabilityIdentity(leaseArg.capability);
@@ -1746,10 +1980,90 @@ export class BrowserRuntime {
1746
1980
  capabilityId: leaseArg.capability.capabilityId,
1747
1981
  leaseId: leaseArg.leaseId,
1748
1982
  action: actionArg,
1983
+ classification: classificationArg,
1749
1984
  startedAt: startedAtArg,
1750
1985
  }) as TBrowserRuntimeOperationIdentity;
1751
1986
  }
1752
1987
 
1988
+ private createLeaseAuthority(lease: ILeaseRecord): TBrowserRuntimeLeaseAuthority {
1989
+ const identity = this.describeCapabilityIdentity(lease.capability);
1990
+ const sessionId = identity.role === 'agent'
1991
+ ? Object.freeze({ ...identity.sessionId })
1992
+ : undefined;
1993
+ return Object.freeze({
1994
+ ...identity,
1995
+ ...(sessionId ? { sessionId } : {}),
1996
+ runtimeAuthorityId: this.runtimeAuthorityId,
1997
+ authorityGeneration: lease.authorityGeneration,
1998
+ incarnationGeneration: lease.slot.incarnationGeneration,
1999
+ capabilityId: lease.capability.capabilityId,
2000
+ leaseId: lease.leaseId,
2001
+ }) as TBrowserRuntimeLeaseAuthority;
2002
+ }
2003
+
2004
+ private validateLeaseAuthority(value: unknown): TBrowserRuntimeLeaseAuthority {
2005
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2006
+ throw new BrowserRuntimeError('INVALID_INPUT');
2007
+ }
2008
+ const candidate = value as Record<string, unknown>;
2009
+ const isFlex = candidate.role === 'agent' && candidate.source === 'flex';
2010
+ const isMcp = candidate.role === 'agent' && candidate.source === 'mcp';
2011
+ const isHuman = candidate.role === 'human' && candidate.source === 'human';
2012
+ if (!isFlex && !isMcp && !isHuman) throw new BrowserRuntimeError('INVALID_INPUT');
2013
+ const record = validateExactKeys(value, [
2014
+ 'projectId',
2015
+ 'browserResourceId',
2016
+ 'attachmentAuthorityId',
2017
+ 'attachmentRevision',
2018
+ 'actorId',
2019
+ 'peerId',
2020
+ 'role',
2021
+ 'source',
2022
+ 'runtimeAuthorityId',
2023
+ 'authorityGeneration',
2024
+ 'incarnationGeneration',
2025
+ 'capabilityId',
2026
+ 'leaseId',
2027
+ ...(isFlex ? ['sessionId', 'scopeId', 'channelId', 'runId'] : []),
2028
+ ...(isMcp ? ['sessionId'] : []),
2029
+ ], 'lease authority');
2030
+ const identity = this.validateExpectedCapabilityBinding(
2031
+ record as unknown as TBrowserCapabilityAuthorizationRequest,
2032
+ );
2033
+ const runtimeAuthorityId = validateBoundedString(
2034
+ record.runtimeAuthorityId,
2035
+ 'runtimeAuthorityId',
2036
+ 16,
2037
+ 128,
2038
+ );
2039
+ const capabilityId = validateBoundedString(record.capabilityId, 'capabilityId', 1, 128);
2040
+ const leaseId = validateBoundedString(record.leaseId, 'leaseId', 1, 128);
2041
+ return {
2042
+ ...identity,
2043
+ runtimeAuthorityId,
2044
+ authorityGeneration: validateInteger(
2045
+ record.authorityGeneration,
2046
+ 'authorityGeneration',
2047
+ 1,
2048
+ Number.MAX_SAFE_INTEGER,
2049
+ ),
2050
+ incarnationGeneration: validateInteger(
2051
+ record.incarnationGeneration,
2052
+ 'incarnationGeneration',
2053
+ 1,
2054
+ Number.MAX_SAFE_INTEGER,
2055
+ ),
2056
+ capabilityId,
2057
+ leaseId,
2058
+ } as TBrowserRuntimeLeaseAuthority;
2059
+ }
2060
+
2061
+ private classifyAgentAction(
2062
+ action: TBrowserAgentAction,
2063
+ ): TBrowserRuntimeOperationClassification {
2064
+ return action.action === 'navigate' ? 'navigation' : 'agent-action';
2065
+ }
2066
+
1753
2067
  private async executeAgentActionInternal(
1754
2068
  lease: ILeaseRecord,
1755
2069
  session: ILiveBrowserSessionLike,
@@ -1897,12 +2211,22 @@ export class BrowserRuntime {
1897
2211
  }
1898
2212
  const subscription = slot.frameSubscription;
1899
2213
  if (event.type === 'frame') {
1900
- const acknowledgement = {
1901
- tabId: event.frame.tabId,
1902
- sequence: event.frame.sequence,
1903
- generation: event.frame.generation,
1904
- viewportRevision: event.frame.viewportRevision,
1905
- };
2214
+ let acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
2215
+ try {
2216
+ if (!(event.frame.data instanceof Uint8Array)) {
2217
+ throw new BrowserRuntimeError('INVALID_INPUT');
2218
+ }
2219
+ acknowledgement = this.validateFrameAcknowledgement(event.frame);
2220
+ } catch {
2221
+ const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2222
+ this.trackCleanup(this.handleFrameFailure(
2223
+ slot,
2224
+ session,
2225
+ slot.incarnationGeneration,
2226
+ failedLease,
2227
+ ));
2228
+ return;
2229
+ }
1906
2230
  if (!subscription || subscription.closed) {
1907
2231
  this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1908
2232
  return;
@@ -1914,10 +2238,17 @@ export class BrowserRuntime {
1914
2238
  this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1915
2239
  return;
1916
2240
  }
1917
- if (subscription.outstanding) {
1918
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2241
+ const key = this.frameAcknowledgementKey(acknowledgement);
2242
+ if (acknowledgement.sequence <= subscription.highestSequence) {
2243
+ this.trackCleanup(this.handleFrameFailure(
2244
+ slot,
2245
+ session,
2246
+ slot.incarnationGeneration,
2247
+ subscription.lease,
2248
+ ));
1919
2249
  return;
1920
2250
  }
2251
+ subscription.highestSequence = acknowledgement.sequence;
1921
2252
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
1922
2253
  this.trackCleanup(this.acknowledgeFrameInBackground(
1923
2254
  slot,
@@ -1931,18 +2262,23 @@ export class BrowserRuntime {
1931
2262
  });
1932
2263
  return;
1933
2264
  }
2265
+ while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2266
+ const oldestKey = subscription.outstanding.keys().next().value as string | undefined;
2267
+ if (!oldestKey) break;
2268
+ this.trackCleanup(
2269
+ this.retireOutstandingFrame(subscription, oldestKey, false).then(() => undefined),
2270
+ );
2271
+ }
1934
2272
  const timer = setTimeout(() => {
1935
- if (subscription.outstanding?.acknowledgement.sequence !== acknowledgement.sequence) return;
1936
- subscription.outstanding = undefined;
1937
- this.trackCleanup(this.acknowledgeFrameInBackground(
1938
- slot,
1939
- session,
1940
- acknowledgement,
1941
- true,
1942
- ));
2273
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
1943
2274
  }, this.options.frameAcknowledgementTimeoutMs);
1944
2275
  timer.unref();
1945
- subscription.outstanding = { acknowledgement, timer };
2276
+ subscription.outstanding.set(key, {
2277
+ acknowledgement,
2278
+ timer,
2279
+ session,
2280
+ incarnationGeneration: slot.incarnationGeneration,
2281
+ });
1946
2282
  this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
1947
2283
  return;
1948
2284
  }
@@ -1975,16 +2311,9 @@ export class BrowserRuntime {
1975
2311
  const slot = subscription.lease.slot;
1976
2312
  const session = slot.session;
1977
2313
  const incarnationGeneration = slot.incarnationGeneration;
1978
- if (event.type === 'frame' && subscription.outstanding) {
1979
- const outstanding = subscription.outstanding;
1980
- clearTimeout(outstanding.timer);
1981
- subscription.outstanding = undefined;
1982
- this.trackCleanup(this.acknowledgeFrameInBackground(
1983
- slot,
1984
- session!,
1985
- outstanding.acknowledgement,
1986
- true,
1987
- ));
2314
+ if (event.type === 'frame') {
2315
+ const key = this.frameAcknowledgementKey(this.validateFrameAcknowledgement(event.frame));
2316
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
1988
2317
  return;
1989
2318
  }
1990
2319
  if (!session) {
@@ -2008,27 +2337,91 @@ export class BrowserRuntime {
2008
2337
  subscription.closed = true;
2009
2338
  const slot = subscription.lease.slot;
2010
2339
  if (slot.frameSubscription === subscription) slot.frameSubscription = undefined;
2011
- if (subscription.outstanding) {
2012
- clearTimeout(subscription.outstanding.timer);
2013
- const acknowledgement = subscription.outstanding.acknowledgement;
2014
- subscription.outstanding = undefined;
2015
- const session = slot.session;
2016
- if (session) {
2017
- const incarnationGeneration = slot.incarnationGeneration;
2018
- const operation = session.acknowledgeFrame(acknowledgement);
2019
- const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
2020
- .catch(() => ({ settled: true as const, value: { accepted: false } }));
2021
- if ((!result.settled || !result.value.accepted) && slot.session === session) {
2022
- operation.catch(() => undefined);
2023
- await this.options.beforeFrameFailureTermination?.();
2024
- await this.handleFrameFailure(
2025
- slot,
2026
- session,
2027
- incarnationGeneration,
2028
- subscription.lease,
2029
- );
2030
- }
2340
+ for (const key of [...subscription.outstanding.keys()]) {
2341
+ await this.retireOutstandingFrame(subscription, key, false);
2342
+ }
2343
+ }
2344
+
2345
+ private async retireOutstandingFrame(
2346
+ subscription: IFrameSubscriptionRecord,
2347
+ key: string,
2348
+ failLease: boolean,
2349
+ ): Promise<boolean> {
2350
+ const outstanding = subscription.outstanding.get(key);
2351
+ if (!outstanding) return false;
2352
+ subscription.outstanding.delete(key);
2353
+ clearTimeout(outstanding.timer);
2354
+ const outcome = await this.waitForFrameAcknowledgement(
2355
+ outstanding.session,
2356
+ outstanding.acknowledgement,
2357
+ );
2358
+ if (outcome.status === 'fulfilled' && !failLease) return outcome.accepted;
2359
+ await this.options.beforeFrameFailureTermination?.();
2360
+ await this.handleFrameFailure(
2361
+ subscription.lease.slot,
2362
+ outstanding.session,
2363
+ outstanding.incarnationGeneration,
2364
+ subscription.lease,
2365
+ );
2366
+ return outcome.status === 'fulfilled' ? outcome.accepted : false;
2367
+ }
2368
+
2369
+ private validateFrameAcknowledgement(
2370
+ value: unknown,
2371
+ exact = false,
2372
+ ): plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest {
2373
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2374
+ throw new BrowserRuntimeError('INVALID_INPUT');
2375
+ }
2376
+ const record = exact
2377
+ ? validateExactKeys(
2378
+ value,
2379
+ ['tabId', 'sequence', 'generation', 'viewportRevision'],
2380
+ 'frame acknowledgement',
2381
+ )
2382
+ : value as Record<string, unknown>;
2383
+ const tabId = validateBoundedString(record.tabId, 'tabId', 1, 128);
2384
+ if (tabId !== record.tabId) throw new BrowserRuntimeError('INVALID_INPUT');
2385
+ return {
2386
+ tabId,
2387
+ sequence: validateInteger(record.sequence, 'sequence', 1, Number.MAX_SAFE_INTEGER),
2388
+ generation: validateInteger(record.generation, 'generation', 0, Number.MAX_SAFE_INTEGER),
2389
+ viewportRevision: validateInteger(
2390
+ record.viewportRevision,
2391
+ 'viewportRevision',
2392
+ 1,
2393
+ Number.MAX_SAFE_INTEGER,
2394
+ ),
2395
+ };
2396
+ }
2397
+
2398
+ private frameAcknowledgementKey(
2399
+ acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2400
+ ): string {
2401
+ return `${acknowledgement.tabId.length}:${acknowledgement.tabId}`
2402
+ + `:${acknowledgement.sequence}:${acknowledgement.generation}`
2403
+ + `:${acknowledgement.viewportRevision}`;
2404
+ }
2405
+
2406
+ private async waitForFrameAcknowledgement(
2407
+ session: ILiveBrowserSessionLike,
2408
+ acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2409
+ ): Promise<TFrameAcknowledgementOutcome> {
2410
+ let operation: Promise<plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgement>;
2411
+ try {
2412
+ operation = session.acknowledgeFrame(acknowledgement);
2413
+ } catch {
2414
+ return { status: 'rejected' };
2415
+ }
2416
+ try {
2417
+ const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs);
2418
+ if (!result.settled) {
2419
+ operation.catch(() => undefined);
2420
+ return { status: 'timedOut' };
2031
2421
  }
2422
+ return { status: 'fulfilled', accepted: result.value.accepted };
2423
+ } catch {
2424
+ return { status: 'rejected' };
2032
2425
  }
2033
2426
  }
2034
2427
 
@@ -2038,13 +2431,10 @@ export class BrowserRuntime {
2038
2431
  acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2039
2432
  failLease = false,
2040
2433
  ): Promise<void> {
2041
- const failedLease = slot.session === session ? slot.lease : undefined;
2434
+ const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2042
2435
  const incarnationGeneration = slot.incarnationGeneration;
2043
- const operation = session.acknowledgeFrame(acknowledgement);
2044
- const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
2045
- .catch(() => ({ settled: true as const, value: { accepted: false } }));
2046
- if (result.settled && result.value.accepted && !failLease) return;
2047
- operation.catch(() => undefined);
2436
+ const outcome = await this.waitForFrameAcknowledgement(session, acknowledgement);
2437
+ if (outcome.status === 'fulfilled' && !failLease) return;
2048
2438
  await this.options.beforeFrameFailureTermination?.();
2049
2439
  await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
2050
2440
  }
@@ -2053,27 +2443,43 @@ export class BrowserRuntime {
2053
2443
  slot: IResourceSlot,
2054
2444
  session: ILiveBrowserSessionLike,
2055
2445
  incarnationGeneration: number,
2056
- lease?: ILeaseRecord,
2446
+ expectedLease: ILeaseRecord | null,
2057
2447
  ): Promise<void> {
2058
2448
  let transitionGeneration = 0;
2059
2449
  let ownsTransitionFence = false;
2450
+ const lease = expectedLease ?? undefined;
2060
2451
  const release = await slot.mutex.acquire();
2061
2452
  try {
2062
- if (lease) this.invalidateCapabilityRecord(lease.capability);
2453
+ const exactLeaseIsCurrent = expectedLease === null
2454
+ ? slot.lease === undefined
2455
+ : slot.lease === expectedLease && expectedLease.capability.lease === expectedLease;
2456
+ let leaseAttachmentIsCurrent = true;
2457
+ if (lease) {
2458
+ try {
2459
+ this.assertCapabilityStillAttached(lease.capability, slot);
2460
+ } catch {
2461
+ leaseAttachmentIsCurrent = false;
2462
+ }
2463
+ }
2063
2464
  const borrowsLeaseReleaseFence = Boolean(
2064
- lease?.releaseGeneration !== undefined
2465
+ lease
2466
+ && exactLeaseIsCurrent
2467
+ && lease.releaseGeneration !== undefined
2065
2468
  && slot.fencingGeneration === lease.releaseGeneration,
2066
2469
  );
2067
2470
  if (
2068
2471
  slot.session !== session
2069
2472
  || slot.incarnationGeneration !== incarnationGeneration
2070
- || (lease && slot.lease !== lease)
2071
- || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
2473
+ || slot.permanentlyFenced
2474
+ || slot.retirementPending
2475
+ || slot.terminationFenceGeneration !== undefined
2476
+ || slot.attachmentFenceCount > 0
2072
2477
  || slot.attachmentRetryBinding
2073
- ) {
2074
- if (lease) this.trackFrameCapabilityRevocation(lease);
2075
- return;
2076
- }
2478
+ || !exactLeaseIsCurrent
2479
+ || !leaseAttachmentIsCurrent
2480
+ || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
2481
+ ) return;
2482
+ if (lease) this.invalidateCapabilityRecord(lease.capability);
2077
2483
  if (borrowsLeaseReleaseFence) {
2078
2484
  transitionGeneration = slot.fencingGeneration!;
2079
2485
  } else {
@@ -2848,13 +3254,19 @@ export class BrowserRuntime {
2848
3254
  const channelId = value.channelId === undefined
2849
3255
  ? undefined
2850
3256
  : this.validateIdentifier(value.channelId, 'channelId');
3257
+ const runId = value.runId === undefined
3258
+ ? undefined
3259
+ : this.validateIdentifier(value.runId, 'runId');
2851
3260
  if (
2852
3261
  (role === 'agent' && !sessionId)
2853
3262
  || (role === 'human' && sessionId !== undefined)
2854
- || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
3263
+ || (source === 'flex' && (!scopeId || !channelId || !runId
3264
+ || sessionId?.harnessId !== 'flex'))
2855
3265
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
3266
+ || runId !== undefined
2856
3267
  || sessionId?.harnessId !== 'opencode'))
2857
- || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
3268
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined
3269
+ || runId !== undefined))
2858
3270
  ) throw new BrowserRuntimeError('INVALID_INPUT');
2859
3271
  const base = {
2860
3272
  projectId: this.validateIdentifier(value.projectId, 'projectId'),
@@ -2876,7 +3288,15 @@ export class BrowserRuntime {
2876
3288
  return { ...base, role, source };
2877
3289
  }
2878
3290
  if (role === 'agent' && source === 'flex' && sessionId?.harnessId === 'flex') {
2879
- return { ...base, role, source, sessionId, scopeId: scopeId!, channelId: channelId! };
3291
+ return {
3292
+ ...base,
3293
+ role,
3294
+ source,
3295
+ sessionId,
3296
+ scopeId: scopeId!,
3297
+ channelId: channelId!,
3298
+ runId: runId!,
3299
+ };
2880
3300
  }
2881
3301
  if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
2882
3302
  return { ...base, role, source, sessionId };
@@ -2910,6 +3330,7 @@ export class BrowserRuntime {
2910
3330
  sessionId: capability.sessionId,
2911
3331
  scopeId: capability.scopeId!,
2912
3332
  channelId: capability.channelId!,
3333
+ runId: capability.runId!,
2913
3334
  };
2914
3335
  }
2915
3336
  if (
@@ -2941,6 +3362,7 @@ export class BrowserRuntime {
2941
3362
  && left.source === right.source
2942
3363
  && left.scopeId === right.scopeId
2943
3364
  && left.channelId === right.channelId
3365
+ && left.runId === right.runId
2944
3366
  && this.sessionsEqual(left.sessionId, right.sessionId);
2945
3367
  }
2946
3368
 
@@ -3044,6 +3466,14 @@ export class BrowserRuntimeLease {
3044
3466
  return this.runtime.getLeaseState(this.record);
3045
3467
  }
3046
3468
 
3469
+ public getAuthority(): TBrowserRuntimeLeaseAuthority {
3470
+ return this.runtime.getLeaseAuthority(this.record);
3471
+ }
3472
+
3473
+ public isAuthorityCurrent(authority: TBrowserRuntimeLeaseAuthority): boolean {
3474
+ return this.runtime.isLeaseAuthorityCurrent(this.record, authority);
3475
+ }
3476
+
3047
3477
  public executeAgentAction(
3048
3478
  action: TBrowserAgentAction,
3049
3479
  options?: IBrowserRuntimeOperationOptions,