@modelprofile.com/browser-runtime 2.1.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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') {
@@ -1054,12 +1186,19 @@ export class BrowserRuntime {
1054
1186
  this.requireHuman(lease);
1055
1187
  if (typeof listener !== 'function') throw new BrowserRuntimeError('INVALID_INPUT');
1056
1188
  const slot = lease.slot;
1057
- const release = await slot.mutex.acquire();
1189
+ const release = slot.mutex.tryAcquire();
1190
+ if (!release) throw new BrowserRuntimeError('BUSY');
1058
1191
  let subscription: IFrameSubscriptionRecord;
1059
1192
  try {
1060
1193
  this.requireValidLease(lease);
1061
1194
  if (slot.frameSubscription) throw new BrowserRuntimeError('BUSY');
1062
- subscription = { lease, listener, closed: false };
1195
+ subscription = {
1196
+ lease,
1197
+ listener,
1198
+ outstanding: new Map(),
1199
+ highestSequence: 0,
1200
+ closed: false,
1201
+ };
1063
1202
  slot.frameSubscription = subscription;
1064
1203
  this.pushToSubscription(subscription, {
1065
1204
  type: 'state',
@@ -1076,46 +1215,28 @@ export class BrowserRuntime {
1076
1215
  /** @internal */
1077
1216
  public async acknowledgeLeaseFrame(
1078
1217
  lease: ILeaseRecord,
1079
- acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1218
+ acknowledgementArg: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1080
1219
  ): Promise<boolean> {
1081
1220
  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
- );
1221
+ const acknowledgement = this.validateFrameAcknowledgement(acknowledgementArg, true);
1222
+ const key = this.frameAcknowledgementKey(acknowledgement);
1094
1223
  const slot = lease.slot;
1095
1224
  const subscription = slot.frameSubscription;
1096
1225
  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;
1226
+ if (!subscription || subscription.lease !== lease) return false;
1227
+ const outstanding = subscription.outstanding.get(key);
1228
+ if (!outstanding) return false;
1229
+ subscription.outstanding.delete(key);
1230
+ clearTimeout(outstanding.timer);
1231
+ const { session, incarnationGeneration } = outstanding;
1232
+ const outcome = await this.waitForFrameAcknowledgement(
1233
+ session,
1234
+ outstanding.acknowledgement,
1235
+ );
1236
+ if (outcome.status === 'fulfilled') return outcome.accepted;
1237
+ await this.options.beforeFrameFailureTermination?.();
1238
+ await this.handleFrameFailure(slot, session, incarnationGeneration, subscription.lease);
1239
+ return false;
1119
1240
  }
1120
1241
 
1121
1242
  /** @internal */
@@ -1229,7 +1350,9 @@ export class BrowserRuntime {
1229
1350
  }
1230
1351
 
1231
1352
  private requireRunning(): void {
1232
- if (this.lifecycleState !== 'running') throw new BrowserRuntimeError('NOT_RUNNING');
1353
+ if (this.lifecycleState !== 'running' || this.lifecycleController.signal.aborted) {
1354
+ throw new BrowserRuntimeError('NOT_RUNNING');
1355
+ }
1233
1356
  }
1234
1357
 
1235
1358
  private async acquireAgentLease(
@@ -1347,6 +1470,11 @@ export class BrowserRuntime {
1347
1470
  clearTimeout(slot.idleTimer);
1348
1471
  slot.idleTimer = undefined;
1349
1472
  }
1473
+ if (slot.authorityGeneration >= Number.MAX_SAFE_INTEGER) {
1474
+ slot.permanentlyFenced = true;
1475
+ throw new BrowserRuntimeError('FENCED');
1476
+ }
1477
+ slot.authorityGeneration += 1;
1350
1478
  const lease: ILeaseRecord = {
1351
1479
  leaseId: randomId(18),
1352
1480
  capability,
@@ -1355,6 +1483,7 @@ export class BrowserRuntime {
1355
1483
  role: capability.role,
1356
1484
  slot,
1357
1485
  controller: new AbortController(),
1486
+ authorityGeneration: slot.authorityGeneration,
1358
1487
  released: false,
1359
1488
  };
1360
1489
  slot.lease = lease;
@@ -1387,6 +1516,9 @@ export class BrowserRuntime {
1387
1516
  forceNoSandbox: false,
1388
1517
  usePipe: true,
1389
1518
  allowEvaluation: true,
1519
+ screencast: {
1520
+ maxOutstandingFrames: this.options.maxOutstandingFrames,
1521
+ },
1390
1522
  launchOptions: {
1391
1523
  headless: true,
1392
1524
  userDataDir: profileDirectory,
@@ -1503,9 +1635,10 @@ export class BrowserRuntime {
1503
1635
  }
1504
1636
  }
1505
1637
 
1506
- private async runOperation<T>(
1638
+ private runOperation<T>(
1507
1639
  lease: ILeaseRecord,
1508
1640
  action: string,
1641
+ classification: TBrowserRuntimeOperationClassification,
1509
1642
  operationOptions: IBrowserRuntimeOperationOptions,
1510
1643
  execute: (signal: AbortSignal, session: ILiveBrowserSessionLike) => Promise<T>,
1511
1644
  ): Promise<T> {
@@ -1522,10 +1655,94 @@ export class BrowserRuntime {
1522
1655
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1523
1656
  if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1524
1657
  const slot = lease.slot;
1525
- const release = slot.mutex.tryAcquire();
1526
- if (!release) throw new BrowserRuntimeError('BUSY');
1658
+ this.requireValidLease(lease);
1659
+ this.assertSlotAvailable(slot);
1660
+ if (slot.operationQueue.length >= this.options.maxQueuedOperationsPerLease) {
1661
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1662
+ }
1663
+ const timeoutMs = operationOptions.timeoutMs === undefined
1664
+ ? this.options.operationTimeoutMs
1665
+ : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1666
+ const signal = AbortSignal.any([
1667
+ lease.controller.signal,
1668
+ this.lifecycleController.signal,
1669
+ operationOptions.signal ?? new AbortController().signal,
1670
+ ]);
1671
+ return new Promise<T>((resolve, reject) => {
1672
+ const queued: IQueuedOperationRecord = {
1673
+ operationId: randomId(18),
1674
+ lease,
1675
+ action,
1676
+ classification,
1677
+ timeoutMs,
1678
+ externalSignal: operationOptions.signal,
1679
+ onOperationStarted: operationOptions.onOperationStarted,
1680
+ signal,
1681
+ execute: execute as IQueuedOperationRecord['execute'],
1682
+ resolve: (value) => resolve(value as T),
1683
+ reject,
1684
+ state: 'queued',
1685
+ };
1686
+ const onQueuedAbort = (): void => {
1687
+ if (queued.state !== 'queued' && queued.state !== 'starting') return;
1688
+ if (queued.state === 'queued') {
1689
+ const index = slot.operationQueue.indexOf(queued);
1690
+ if (index >= 0) slot.operationQueue.splice(index, 1);
1691
+ }
1692
+ queued.state = 'settled';
1693
+ signal.removeEventListener('abort', onQueuedAbort);
1694
+ reject(this.queuedOperationAbortError(queued));
1695
+ };
1696
+ queued.onQueuedAbort = onQueuedAbort;
1697
+ signal.addEventListener('abort', onQueuedAbort, { once: true });
1698
+ if (signal.aborted) {
1699
+ onQueuedAbort();
1700
+ return;
1701
+ }
1702
+ slot.operationQueue.push(queued);
1703
+ this.drainOperationQueue(slot);
1704
+ });
1705
+ }
1706
+
1707
+ private drainOperationQueue(slot: IResourceSlot): void {
1708
+ if (slot.operationSchedulerRunning) return;
1709
+ slot.operationSchedulerRunning = true;
1710
+ const scheduler = (async () => {
1711
+ while (slot.operationQueue.length > 0) {
1712
+ const queued = slot.operationQueue.shift()!;
1713
+ if (queued.state === 'settled') continue;
1714
+ queued.state = 'starting';
1715
+ try {
1716
+ const result = await this.executeQueuedOperation(queued);
1717
+ if ((queued as IQueuedOperationRecord).state !== 'settled') {
1718
+ queued.state = 'settled';
1719
+ queued.resolve(result);
1720
+ }
1721
+ } catch (error) {
1722
+ if (queued.state !== 'settled') {
1723
+ queued.state = 'settled';
1724
+ queued.reject(error);
1725
+ }
1726
+ } finally {
1727
+ if (queued.onQueuedAbort) {
1728
+ queued.signal.removeEventListener('abort', queued.onQueuedAbort);
1729
+ queued.onQueuedAbort = undefined;
1730
+ }
1731
+ }
1732
+ }
1733
+ })().finally(() => {
1734
+ slot.operationSchedulerRunning = false;
1735
+ if (slot.operationQueue.length > 0) this.drainOperationQueue(slot);
1736
+ });
1737
+ void scheduler.catch(() => undefined);
1738
+ }
1739
+
1740
+ private async executeQueuedOperation(queued: IQueuedOperationRecord): Promise<unknown> {
1741
+ const { lease, action, classification } = queued;
1742
+ const slot = lease.slot;
1743
+ const release = await slot.mutex.acquire();
1527
1744
  let operation: IOperationRecord;
1528
- let executionPromise: Promise<T> | undefined;
1745
+ let executionPromise: Promise<unknown> | undefined;
1529
1746
  let combinedSignal: AbortSignal;
1530
1747
  let timeout: ReturnType<typeof setTimeout>;
1531
1748
  let session: ILiveBrowserSessionLike;
@@ -1534,35 +1751,34 @@ export class BrowserRuntime {
1534
1751
  let externalAbort = false;
1535
1752
  let onExternalAbort: (() => void) | undefined;
1536
1753
  let resolvePreflight!: () => void;
1537
- const startedAt = Date.now();
1754
+ let startedAt: number;
1538
1755
  try {
1756
+ if (queued.state === 'settled') return undefined;
1757
+ queued.signal.throwIfAborted();
1539
1758
  this.requireValidLease(lease);
1540
1759
  this.assertSlotAvailable(slot);
1541
1760
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1542
1761
  session = slot.session!;
1543
1762
  generation = slot.arbitrationGeneration;
1544
1763
  incarnationGeneration = slot.incarnationGeneration;
1545
- const timeoutMs = operationOptions.timeoutMs === undefined
1546
- ? this.options.operationTimeoutMs
1547
- : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
1764
+ startedAt = Date.now();
1548
1765
  const controller = new AbortController();
1549
1766
  const timeoutController = new AbortController();
1550
1767
  timeout = setTimeout(() => {
1551
1768
  timeoutController.abort(new BrowserRuntimeError('TIMEOUT'));
1552
- }, timeoutMs);
1769
+ }, queued.timeoutMs);
1553
1770
  timeout.unref();
1554
- const externalSignal = operationOptions.signal;
1771
+ const externalSignal = queued.externalSignal;
1555
1772
  onExternalAbort = () => { externalAbort = true; };
1556
1773
  externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
1557
1774
  if (externalSignal?.aborted) externalAbort = true;
1558
1775
  combinedSignal = AbortSignal.any([
1559
1776
  controller.signal,
1560
- lease.controller.signal,
1561
- externalSignal ?? new AbortController().signal,
1777
+ queued.signal,
1562
1778
  timeoutController.signal,
1563
1779
  ]);
1564
1780
  operation = {
1565
- operationId: randomId(18),
1781
+ operationId: queued.operationId,
1566
1782
  lease,
1567
1783
  controller,
1568
1784
  action,
@@ -1572,8 +1788,13 @@ export class BrowserRuntime {
1572
1788
  promise: new Promise<void>((resolve) => { resolvePreflight = resolve; }),
1573
1789
  };
1574
1790
  slot.operation = operation;
1791
+ queued.state = 'active';
1792
+ if (queued.onQueuedAbort) {
1793
+ queued.signal.removeEventListener('abort', queued.onQueuedAbort);
1794
+ queued.onQueuedAbort = undefined;
1795
+ }
1575
1796
  try {
1576
- operationOptions.onOperationStarted?.(operation.operationId);
1797
+ queued.onOperationStarted?.(operation.operationId);
1577
1798
  } catch {
1578
1799
  controller.abort(new BrowserRuntimeError('ABORTED'));
1579
1800
  }
@@ -1581,9 +1802,15 @@ export class BrowserRuntime {
1581
1802
  release();
1582
1803
  }
1583
1804
 
1584
- const operationIdentity = this.createOperationIdentity(lease, operation, action, startedAt);
1805
+ const operationIdentity = this.createOperationIdentity(
1806
+ lease,
1807
+ operation,
1808
+ action,
1809
+ classification,
1810
+ startedAt,
1811
+ );
1585
1812
 
1586
- let result: T | undefined;
1813
+ let result: unknown;
1587
1814
  let failed = false;
1588
1815
  let failure: unknown;
1589
1816
  let executionStarted = false;
@@ -1602,7 +1829,7 @@ export class BrowserRuntime {
1602
1829
  || slot.incarnationGeneration !== incarnationGeneration
1603
1830
  ) throw new BrowserRuntimeError('ABORTED');
1604
1831
  executionStarted = true;
1605
- executionPromise = execute(combinedSignal, session);
1832
+ executionPromise = queued.execute(combinedSignal, session);
1606
1833
  operation.promise = executionPromise.then(() => undefined, () => undefined);
1607
1834
  } finally {
1608
1835
  executionRelease();
@@ -1628,7 +1855,7 @@ export class BrowserRuntime {
1628
1855
  failure = this.normalizeOperationError(error, combinedSignal!, externalAbort);
1629
1856
  } finally {
1630
1857
  clearTimeout(timeout!);
1631
- operationOptions.signal?.removeEventListener('abort', onExternalAbort!);
1858
+ queued.externalSignal?.removeEventListener('abort', onExternalAbort!);
1632
1859
  resolvePreflight();
1633
1860
  const cleanupSlot = async (): Promise<void> => {
1634
1861
  const cleanupRelease = await slot.mutex.acquire();
@@ -1658,7 +1885,14 @@ export class BrowserRuntime {
1658
1885
  }));
1659
1886
  }
1660
1887
  if (failed) throw failure;
1661
- return result as T;
1888
+ return result;
1889
+ }
1890
+
1891
+ private queuedOperationAbortError(queued: IQueuedOperationRecord): BrowserRuntimeError {
1892
+ if (queued.externalSignal?.aborted) return new BrowserRuntimeError('ABORTED');
1893
+ return queued.signal.reason instanceof BrowserRuntimeError
1894
+ ? queued.signal.reason
1895
+ : new BrowserRuntimeError('ABORTED');
1662
1896
  }
1663
1897
 
1664
1898
  private normalizeOperationError(
@@ -1733,6 +1967,7 @@ export class BrowserRuntime {
1733
1967
  leaseArg: ILeaseRecord,
1734
1968
  operationArg: IOperationRecord,
1735
1969
  actionArg: string,
1970
+ classificationArg: TBrowserRuntimeOperationClassification,
1736
1971
  startedAtArg: number,
1737
1972
  ): TBrowserRuntimeOperationIdentity {
1738
1973
  const identity = this.describeCapabilityIdentity(leaseArg.capability);
@@ -1746,10 +1981,90 @@ export class BrowserRuntime {
1746
1981
  capabilityId: leaseArg.capability.capabilityId,
1747
1982
  leaseId: leaseArg.leaseId,
1748
1983
  action: actionArg,
1984
+ classification: classificationArg,
1749
1985
  startedAt: startedAtArg,
1750
1986
  }) as TBrowserRuntimeOperationIdentity;
1751
1987
  }
1752
1988
 
1989
+ private createLeaseAuthority(lease: ILeaseRecord): TBrowserRuntimeLeaseAuthority {
1990
+ const identity = this.describeCapabilityIdentity(lease.capability);
1991
+ const sessionId = identity.role === 'agent'
1992
+ ? Object.freeze({ ...identity.sessionId })
1993
+ : undefined;
1994
+ return Object.freeze({
1995
+ ...identity,
1996
+ ...(sessionId ? { sessionId } : {}),
1997
+ runtimeAuthorityId: this.runtimeAuthorityId,
1998
+ authorityGeneration: lease.authorityGeneration,
1999
+ incarnationGeneration: lease.slot.incarnationGeneration,
2000
+ capabilityId: lease.capability.capabilityId,
2001
+ leaseId: lease.leaseId,
2002
+ }) as TBrowserRuntimeLeaseAuthority;
2003
+ }
2004
+
2005
+ private validateLeaseAuthority(value: unknown): TBrowserRuntimeLeaseAuthority {
2006
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2007
+ throw new BrowserRuntimeError('INVALID_INPUT');
2008
+ }
2009
+ const candidate = value as Record<string, unknown>;
2010
+ const isFlex = candidate.role === 'agent' && candidate.source === 'flex';
2011
+ const isMcp = candidate.role === 'agent' && candidate.source === 'mcp';
2012
+ const isHuman = candidate.role === 'human' && candidate.source === 'human';
2013
+ if (!isFlex && !isMcp && !isHuman) throw new BrowserRuntimeError('INVALID_INPUT');
2014
+ const record = validateExactKeys(value, [
2015
+ 'projectId',
2016
+ 'browserResourceId',
2017
+ 'attachmentAuthorityId',
2018
+ 'attachmentRevision',
2019
+ 'actorId',
2020
+ 'peerId',
2021
+ 'role',
2022
+ 'source',
2023
+ 'runtimeAuthorityId',
2024
+ 'authorityGeneration',
2025
+ 'incarnationGeneration',
2026
+ 'capabilityId',
2027
+ 'leaseId',
2028
+ ...(isFlex ? ['sessionId', 'scopeId', 'channelId', 'runId'] : []),
2029
+ ...(isMcp ? ['sessionId'] : []),
2030
+ ], 'lease authority');
2031
+ const identity = this.validateExpectedCapabilityBinding(
2032
+ record as unknown as TBrowserCapabilityAuthorizationRequest,
2033
+ );
2034
+ const runtimeAuthorityId = validateBoundedString(
2035
+ record.runtimeAuthorityId,
2036
+ 'runtimeAuthorityId',
2037
+ 16,
2038
+ 128,
2039
+ );
2040
+ const capabilityId = validateBoundedString(record.capabilityId, 'capabilityId', 1, 128);
2041
+ const leaseId = validateBoundedString(record.leaseId, 'leaseId', 1, 128);
2042
+ return {
2043
+ ...identity,
2044
+ runtimeAuthorityId,
2045
+ authorityGeneration: validateInteger(
2046
+ record.authorityGeneration,
2047
+ 'authorityGeneration',
2048
+ 1,
2049
+ Number.MAX_SAFE_INTEGER,
2050
+ ),
2051
+ incarnationGeneration: validateInteger(
2052
+ record.incarnationGeneration,
2053
+ 'incarnationGeneration',
2054
+ 1,
2055
+ Number.MAX_SAFE_INTEGER,
2056
+ ),
2057
+ capabilityId,
2058
+ leaseId,
2059
+ } as TBrowserRuntimeLeaseAuthority;
2060
+ }
2061
+
2062
+ private classifyAgentAction(
2063
+ action: TBrowserAgentAction,
2064
+ ): TBrowserRuntimeOperationClassification {
2065
+ return action.action === 'navigate' ? 'navigation' : 'agent-action';
2066
+ }
2067
+
1753
2068
  private async executeAgentActionInternal(
1754
2069
  lease: ILeaseRecord,
1755
2070
  session: ILiveBrowserSessionLike,
@@ -1897,12 +2212,22 @@ export class BrowserRuntime {
1897
2212
  }
1898
2213
  const subscription = slot.frameSubscription;
1899
2214
  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
- };
2215
+ let acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest;
2216
+ try {
2217
+ if (!(event.frame.data instanceof Uint8Array)) {
2218
+ throw new BrowserRuntimeError('INVALID_INPUT');
2219
+ }
2220
+ acknowledgement = this.validateFrameAcknowledgement(event.frame);
2221
+ } catch {
2222
+ const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2223
+ this.trackCleanup(this.handleFrameFailure(
2224
+ slot,
2225
+ session,
2226
+ slot.incarnationGeneration,
2227
+ failedLease,
2228
+ ));
2229
+ return;
2230
+ }
1906
2231
  if (!subscription || subscription.closed) {
1907
2232
  this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1908
2233
  return;
@@ -1914,10 +2239,17 @@ export class BrowserRuntime {
1914
2239
  this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1915
2240
  return;
1916
2241
  }
1917
- if (subscription.outstanding) {
1918
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
2242
+ const key = this.frameAcknowledgementKey(acknowledgement);
2243
+ if (acknowledgement.sequence <= subscription.highestSequence) {
2244
+ this.trackCleanup(this.handleFrameFailure(
2245
+ slot,
2246
+ session,
2247
+ slot.incarnationGeneration,
2248
+ subscription.lease,
2249
+ ));
1919
2250
  return;
1920
2251
  }
2252
+ subscription.highestSequence = acknowledgement.sequence;
1921
2253
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
1922
2254
  this.trackCleanup(this.acknowledgeFrameInBackground(
1923
2255
  slot,
@@ -1931,18 +2263,23 @@ export class BrowserRuntime {
1931
2263
  });
1932
2264
  return;
1933
2265
  }
2266
+ while (subscription.outstanding.size >= this.options.maxOutstandingFrames) {
2267
+ const oldestKey = subscription.outstanding.keys().next().value as string | undefined;
2268
+ if (!oldestKey) break;
2269
+ this.trackCleanup(
2270
+ this.retireOutstandingFrame(subscription, oldestKey, false).then(() => undefined),
2271
+ );
2272
+ }
1934
2273
  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
- ));
2274
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
1943
2275
  }, this.options.frameAcknowledgementTimeoutMs);
1944
2276
  timer.unref();
1945
- subscription.outstanding = { acknowledgement, timer };
2277
+ subscription.outstanding.set(key, {
2278
+ acknowledgement,
2279
+ timer,
2280
+ session,
2281
+ incarnationGeneration: slot.incarnationGeneration,
2282
+ });
1946
2283
  this.pushToSubscription(subscription, { type: 'frame', frame: event.frame });
1947
2284
  return;
1948
2285
  }
@@ -1975,16 +2312,9 @@ export class BrowserRuntime {
1975
2312
  const slot = subscription.lease.slot;
1976
2313
  const session = slot.session;
1977
2314
  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
- ));
2315
+ if (event.type === 'frame') {
2316
+ const key = this.frameAcknowledgementKey(this.validateFrameAcknowledgement(event.frame));
2317
+ this.trackCleanup(this.retireOutstandingFrame(subscription, key, true).then(() => undefined));
1988
2318
  return;
1989
2319
  }
1990
2320
  if (!session) {
@@ -2008,27 +2338,91 @@ export class BrowserRuntime {
2008
2338
  subscription.closed = true;
2009
2339
  const slot = subscription.lease.slot;
2010
2340
  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
- }
2341
+ for (const key of [...subscription.outstanding.keys()]) {
2342
+ await this.retireOutstandingFrame(subscription, key, false);
2343
+ }
2344
+ }
2345
+
2346
+ private async retireOutstandingFrame(
2347
+ subscription: IFrameSubscriptionRecord,
2348
+ key: string,
2349
+ failLease: boolean,
2350
+ ): Promise<boolean> {
2351
+ const outstanding = subscription.outstanding.get(key);
2352
+ if (!outstanding) return false;
2353
+ subscription.outstanding.delete(key);
2354
+ clearTimeout(outstanding.timer);
2355
+ const outcome = await this.waitForFrameAcknowledgement(
2356
+ outstanding.session,
2357
+ outstanding.acknowledgement,
2358
+ );
2359
+ if (outcome.status === 'fulfilled' && !failLease) return outcome.accepted;
2360
+ await this.options.beforeFrameFailureTermination?.();
2361
+ await this.handleFrameFailure(
2362
+ subscription.lease.slot,
2363
+ outstanding.session,
2364
+ outstanding.incarnationGeneration,
2365
+ subscription.lease,
2366
+ );
2367
+ return outcome.status === 'fulfilled' ? outcome.accepted : false;
2368
+ }
2369
+
2370
+ private validateFrameAcknowledgement(
2371
+ value: unknown,
2372
+ exact = false,
2373
+ ): plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest {
2374
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2375
+ throw new BrowserRuntimeError('INVALID_INPUT');
2376
+ }
2377
+ const record = exact
2378
+ ? validateExactKeys(
2379
+ value,
2380
+ ['tabId', 'sequence', 'generation', 'viewportRevision'],
2381
+ 'frame acknowledgement',
2382
+ )
2383
+ : value as Record<string, unknown>;
2384
+ const tabId = validateBoundedString(record.tabId, 'tabId', 1, 128);
2385
+ if (tabId !== record.tabId) throw new BrowserRuntimeError('INVALID_INPUT');
2386
+ return {
2387
+ tabId,
2388
+ sequence: validateInteger(record.sequence, 'sequence', 1, Number.MAX_SAFE_INTEGER),
2389
+ generation: validateInteger(record.generation, 'generation', 0, Number.MAX_SAFE_INTEGER),
2390
+ viewportRevision: validateInteger(
2391
+ record.viewportRevision,
2392
+ 'viewportRevision',
2393
+ 1,
2394
+ Number.MAX_SAFE_INTEGER,
2395
+ ),
2396
+ };
2397
+ }
2398
+
2399
+ private frameAcknowledgementKey(
2400
+ acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2401
+ ): string {
2402
+ return `${acknowledgement.tabId.length}:${acknowledgement.tabId}`
2403
+ + `:${acknowledgement.sequence}:${acknowledgement.generation}`
2404
+ + `:${acknowledgement.viewportRevision}`;
2405
+ }
2406
+
2407
+ private async waitForFrameAcknowledgement(
2408
+ session: ILiveBrowserSessionLike,
2409
+ acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2410
+ ): Promise<TFrameAcknowledgementOutcome> {
2411
+ let operation: Promise<plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgement>;
2412
+ try {
2413
+ operation = session.acknowledgeFrame(acknowledgement);
2414
+ } catch {
2415
+ return { status: 'rejected' };
2416
+ }
2417
+ try {
2418
+ const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs);
2419
+ if (!result.settled) {
2420
+ operation.catch(() => undefined);
2421
+ return { status: 'timedOut' };
2031
2422
  }
2423
+ return { status: 'fulfilled', accepted: result.value.accepted };
2424
+ } catch {
2425
+ return { status: 'rejected' };
2032
2426
  }
2033
2427
  }
2034
2428
 
@@ -2038,13 +2432,10 @@ export class BrowserRuntime {
2038
2432
  acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2039
2433
  failLease = false,
2040
2434
  ): Promise<void> {
2041
- const failedLease = slot.session === session ? slot.lease : undefined;
2435
+ const failedLease = slot.session === session ? (slot.lease ?? null) : null;
2042
2436
  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);
2437
+ const outcome = await this.waitForFrameAcknowledgement(session, acknowledgement);
2438
+ if (outcome.status === 'fulfilled' && !failLease) return;
2048
2439
  await this.options.beforeFrameFailureTermination?.();
2049
2440
  await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
2050
2441
  }
@@ -2053,27 +2444,43 @@ export class BrowserRuntime {
2053
2444
  slot: IResourceSlot,
2054
2445
  session: ILiveBrowserSessionLike,
2055
2446
  incarnationGeneration: number,
2056
- lease?: ILeaseRecord,
2447
+ expectedLease: ILeaseRecord | null,
2057
2448
  ): Promise<void> {
2058
2449
  let transitionGeneration = 0;
2059
2450
  let ownsTransitionFence = false;
2451
+ const lease = expectedLease ?? undefined;
2060
2452
  const release = await slot.mutex.acquire();
2061
2453
  try {
2062
- if (lease) this.invalidateCapabilityRecord(lease.capability);
2454
+ const exactLeaseIsCurrent = expectedLease === null
2455
+ ? slot.lease === undefined
2456
+ : slot.lease === expectedLease && expectedLease.capability.lease === expectedLease;
2457
+ let leaseAttachmentIsCurrent = true;
2458
+ if (lease) {
2459
+ try {
2460
+ this.assertCapabilityStillAttached(lease.capability, slot);
2461
+ } catch {
2462
+ leaseAttachmentIsCurrent = false;
2463
+ }
2464
+ }
2063
2465
  const borrowsLeaseReleaseFence = Boolean(
2064
- lease?.releaseGeneration !== undefined
2466
+ lease
2467
+ && exactLeaseIsCurrent
2468
+ && lease.releaseGeneration !== undefined
2065
2469
  && slot.fencingGeneration === lease.releaseGeneration,
2066
2470
  );
2067
2471
  if (
2068
2472
  slot.session !== session
2069
2473
  || slot.incarnationGeneration !== incarnationGeneration
2070
- || (lease && slot.lease !== lease)
2071
- || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
2474
+ || slot.permanentlyFenced
2475
+ || slot.retirementPending
2476
+ || slot.terminationFenceGeneration !== undefined
2477
+ || slot.attachmentFenceCount > 0
2072
2478
  || slot.attachmentRetryBinding
2073
- ) {
2074
- if (lease) this.trackFrameCapabilityRevocation(lease);
2075
- return;
2076
- }
2479
+ || !exactLeaseIsCurrent
2480
+ || !leaseAttachmentIsCurrent
2481
+ || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
2482
+ ) return;
2483
+ if (lease) this.invalidateCapabilityRecord(lease.capability);
2077
2484
  if (borrowsLeaseReleaseFence) {
2078
2485
  transitionGeneration = slot.fencingGeneration!;
2079
2486
  } else {
@@ -2848,13 +3255,19 @@ export class BrowserRuntime {
2848
3255
  const channelId = value.channelId === undefined
2849
3256
  ? undefined
2850
3257
  : this.validateIdentifier(value.channelId, 'channelId');
3258
+ const runId = value.runId === undefined
3259
+ ? undefined
3260
+ : this.validateIdentifier(value.runId, 'runId');
2851
3261
  if (
2852
3262
  (role === 'agent' && !sessionId)
2853
3263
  || (role === 'human' && sessionId !== undefined)
2854
- || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
3264
+ || (source === 'flex' && (!scopeId || !channelId || !runId
3265
+ || sessionId?.harnessId !== 'flex'))
2855
3266
  || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
3267
+ || runId !== undefined
2856
3268
  || sessionId?.harnessId !== 'opencode'))
2857
- || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
3269
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined
3270
+ || runId !== undefined))
2858
3271
  ) throw new BrowserRuntimeError('INVALID_INPUT');
2859
3272
  const base = {
2860
3273
  projectId: this.validateIdentifier(value.projectId, 'projectId'),
@@ -2876,7 +3289,15 @@ export class BrowserRuntime {
2876
3289
  return { ...base, role, source };
2877
3290
  }
2878
3291
  if (role === 'agent' && source === 'flex' && sessionId?.harnessId === 'flex') {
2879
- return { ...base, role, source, sessionId, scopeId: scopeId!, channelId: channelId! };
3292
+ return {
3293
+ ...base,
3294
+ role,
3295
+ source,
3296
+ sessionId,
3297
+ scopeId: scopeId!,
3298
+ channelId: channelId!,
3299
+ runId: runId!,
3300
+ };
2880
3301
  }
2881
3302
  if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
2882
3303
  return { ...base, role, source, sessionId };
@@ -2910,6 +3331,7 @@ export class BrowserRuntime {
2910
3331
  sessionId: capability.sessionId,
2911
3332
  scopeId: capability.scopeId!,
2912
3333
  channelId: capability.channelId!,
3334
+ runId: capability.runId!,
2913
3335
  };
2914
3336
  }
2915
3337
  if (
@@ -2941,6 +3363,7 @@ export class BrowserRuntime {
2941
3363
  && left.source === right.source
2942
3364
  && left.scopeId === right.scopeId
2943
3365
  && left.channelId === right.channelId
3366
+ && left.runId === right.runId
2944
3367
  && this.sessionsEqual(left.sessionId, right.sessionId);
2945
3368
  }
2946
3369
 
@@ -3044,6 +3467,14 @@ export class BrowserRuntimeLease {
3044
3467
  return this.runtime.getLeaseState(this.record);
3045
3468
  }
3046
3469
 
3470
+ public getAuthority(): TBrowserRuntimeLeaseAuthority {
3471
+ return this.runtime.getLeaseAuthority(this.record);
3472
+ }
3473
+
3474
+ public isAuthorityCurrent(authority: TBrowserRuntimeLeaseAuthority): boolean {
3475
+ return this.runtime.isLeaseAuthorityCurrent(this.record, authority);
3476
+ }
3477
+
3047
3478
  public executeAgentAction(
3048
3479
  action: TBrowserAgentAction,
3049
3480
  options?: IBrowserRuntimeOperationOptions,