@modelprofile.com/browser-runtime 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,19 +10,27 @@ import {
10
10
  } from './internal.testing.js';
11
11
  import type {
12
12
  IAttachTrustedFramedPeerOptions,
13
+ IApplyBrowserAttachmentBindingRequest,
14
+ IBrowserAttachmentBinding,
13
15
  IBrowserActionStateResult,
14
16
  IBrowserArtifactMetadata,
15
- IBrowserCapabilityDescriptor,
17
+ TBrowserCapabilityDescriptor,
18
+ TBrowserCapabilityAuthorizationRequest,
16
19
  IBrowserConfinementProbeContext,
17
20
  IBrowserObservationResult,
18
- IBrowserRuntimeAuditEvent,
21
+ TBrowserRuntimeAuditEvent,
22
+ TBrowserRuntimeOperationIdentity,
19
23
  IBrowserRuntimeFrameSubscription,
20
24
  IBrowserRuntimeOperationOptions,
21
25
  IBrowserRuntimeOptions,
26
+ IBrowserResourceRegistration,
22
27
  IBrowserRuntimeState,
23
28
  IBrowserScreenshotResult,
24
- IIssueBrowserCapabilityRequest,
25
- IIssuedBrowserCapability,
29
+ ICreateBrowserResourceRequest,
30
+ TQualifiedBrowserSessionId,
31
+ IRegisterBrowserResourceRequest,
32
+ TIssueBrowserCapabilityRequest,
33
+ TIssuedBrowserCapability,
26
34
  ILiveBrowserSessionLike,
27
35
  TBrowserActorRole,
28
36
  TBrowserAgentAction,
@@ -46,32 +54,43 @@ import { BrowserRuntimeFramedServerPeer } from './classes.framed.js';
46
54
  interface INormalizedRuntimeOptions {
47
55
  runtimeDirectory: string;
48
56
  authorizeCapability: IBrowserRuntimeOptions['authorizeCapability'];
57
+ beforeOperation?: IBrowserRuntimeOptions['beforeOperation'];
49
58
  audit?: IBrowserRuntimeOptions['audit'];
50
59
  sessionFactory: (
51
60
  options: plugins.smartpuppeteer.ILiveBrowserSessionOptions,
52
61
  ) => ILiveBrowserSessionLike;
53
62
  confinementProbe: (context: IBrowserConfinementProbeContext) => Promise<void>;
54
63
  environment: { platform: NodeJS.Platform; uid: number | undefined };
55
- maxProjectSlots: number;
64
+ maxResources: number;
65
+ maxResourcesPerProject: number;
66
+ maxRunningResources: number;
67
+ maxRunningResourcesPerProject: number;
56
68
  maxCapabilities: number;
69
+ maxCapabilitiesPerResource: number;
57
70
  maxCapabilitiesPerProject: number;
58
71
  capabilityDefaultTtlMs: number;
59
72
  capabilityMaximumTtlMs: number;
60
73
  authorizationTimeoutMs: number;
61
- maxTabsPerProject: number;
74
+ beforeOperationTimeoutMs: number;
75
+ maxTabsPerResource: number;
62
76
  operationTimeoutMs: number;
63
77
  quiescenceTimeoutMs: number;
64
78
  terminationGraceMs: number;
65
79
  terminationForceMs: number;
66
80
  auditTimeoutMs: number;
67
81
  confinementProbeTimeoutMs: number;
68
- idleRetirementMs: number;
82
+ idleTerminationMs: number;
69
83
  maxFrameBytes: number;
70
84
  frameAcknowledgementTimeoutMs: number;
71
85
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
86
+ beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
87
+ beforeIdleTermination?: IBrowserRuntimeTestingOptions['beforeIdleTermination'];
88
+ beforeFrameFailureTermination?: IBrowserRuntimeTestingOptions['beforeFrameFailureTermination'];
89
+ afterFrameFailureFence?: IBrowserRuntimeTestingOptions['afterFrameFailureFence'];
90
+ beforeFramedPeerLeasePublication?: IBrowserRuntimeTestingOptions['beforeFramedPeerLeasePublication'];
72
91
  }
73
92
 
74
- interface ICapabilityRecord extends IBrowserCapabilityDescriptor {
93
+ type TCapabilityRecord = TBrowserCapabilityDescriptor & {
75
94
  digest: plugins.Buffer;
76
95
  state: 'active' | 'revoked' | 'expired';
77
96
  expiryTimer: ReturnType<typeof setTimeout>;
@@ -82,19 +101,21 @@ interface ICapabilityRecord extends IBrowserCapabilityDescriptor {
82
101
  revocationComplete?: boolean;
83
102
  leaseCleanupComplete?: boolean;
84
103
  disconnectComplete?: boolean;
85
- }
104
+ revoking?: boolean;
105
+ };
86
106
 
87
107
  interface ILeaseRecord {
88
108
  leaseId: string;
89
- capability: ICapabilityRecord;
109
+ capability: TCapabilityRecord;
90
110
  digest: plugins.Buffer;
91
111
  peerId: string;
92
112
  role: TBrowserActorRole;
93
- slot: IProjectSlot;
113
+ slot: IResourceSlot;
94
114
  controller: AbortController;
95
115
  released: boolean;
96
116
  releasePromise?: Promise<void>;
97
117
  releaseGeneration?: number;
118
+ releaseOwnsFence?: boolean;
98
119
  }
99
120
 
100
121
  interface IOperationRecord {
@@ -103,6 +124,8 @@ interface IOperationRecord {
103
124
  controller: AbortController;
104
125
  action: string;
105
126
  startedAt: number;
127
+ session: ILiveBrowserSessionLike;
128
+ incarnationGeneration: number;
106
129
  promise: Promise<void>;
107
130
  }
108
131
 
@@ -116,12 +139,19 @@ interface IFrameSubscriptionRecord {
116
139
  closed: boolean;
117
140
  }
118
141
 
119
- interface IProjectSlot {
142
+ interface IResourceSlot {
120
143
  projectId: string;
144
+ browserResourceId: string;
145
+ registeredAt: number;
146
+ attachmentBinding: IBrowserAttachmentBinding;
121
147
  mutex: TransitionMutex;
122
- generation: number;
148
+ arbitrationGeneration: number;
149
+ incarnationGeneration: number;
123
150
  fencingGeneration?: number;
124
151
  permanentlyFenced: boolean;
152
+ retirementPending: boolean;
153
+ attachmentFenceCount: number;
154
+ attachmentRetryBinding?: IBrowserAttachmentBinding;
125
155
  session?: ILiveBrowserSessionLike;
126
156
  proxy?: BrowserEgressProxy;
127
157
  profileDirectory?: string;
@@ -130,21 +160,21 @@ interface IProjectSlot {
130
160
  operation?: IOperationRecord;
131
161
  frameSubscription?: IFrameSubscriptionRecord;
132
162
  idleTimer?: ReturnType<typeof setTimeout>;
163
+ lifecycleTail: Promise<void>;
164
+ lifecycleOperationCount: number;
165
+ terminationRequestGeneration: number;
166
+ terminationFenceGeneration?: number;
167
+ terminatePromise?: Promise<void>;
168
+ retirePromise?: Promise<void>;
169
+ attachmentOperations: Map<string, Promise<IBrowserResourceRegistration>>;
133
170
  }
134
171
 
135
- export interface IAcquireBrowserRuntimeLeaseOptions {
172
+ export type TAcquireBrowserRuntimeLeaseOptions = TBrowserCapabilityAuthorizationRequest & {
136
173
  capabilityToken: string;
137
- peerId: string;
138
- expectedRole?: TBrowserActorRole;
139
- expectedSource?: TBrowserCapabilitySource;
140
- scopeId?: string;
141
- sessionId?: string;
142
174
  signal?: AbortSignal;
143
- }
175
+ };
144
176
 
145
- const projectIdMaximum = 128;
146
- const actorIdMaximum = 128;
147
- const peerIdMaximum = 128;
177
+ const identifierMaximum = 256;
148
178
 
149
179
  export class BrowserRuntime {
150
180
  private readonly options: INormalizedRuntimeOptions;
@@ -152,10 +182,12 @@ export class BrowserRuntime {
152
182
  private readonly artifactRoot: string;
153
183
  private readonly lockPath: string;
154
184
  private readonly artifactStore: BrowserArtifactStore;
155
- private readonly slots = new Map<string, IProjectSlot>();
156
- private readonly capabilitiesByDigest = new Map<string, ICapabilityRecord>();
157
- private readonly capabilitiesById = new Map<string, ICapabilityRecord>();
158
- private readonly failedRevocations = new Set<ICapabilityRecord>();
185
+ private readonly slots = new Map<string, IResourceSlot>();
186
+ private readonly retiredResourceIds = new Uint8Array(64 * 1024);
187
+ private readonly retiredResourceKeys = new Uint8Array(64 * 1024);
188
+ private readonly capabilitiesByDigest = new Map<string, TCapabilityRecord>();
189
+ private readonly capabilitiesById = new Map<string, TCapabilityRecord>();
190
+ private readonly failedRevocations = new Set<TCapabilityRecord>();
159
191
  private readonly capabilityMutex = new TransitionMutex();
160
192
  private readonly cleanupOperations = new Set<Promise<void>>();
161
193
  private readonly framedPeers = new Set<BrowserRuntimeFramedServerPeer>();
@@ -168,6 +200,9 @@ export class BrowserRuntime {
168
200
  private lifecycleController = new AbortController();
169
201
  private pendingCapabilities = 0;
170
202
  private readonly pendingCapabilitiesByProject = new Map<string, number>();
203
+ private readonly pendingCapabilitiesByResource = new Map<string, number>();
204
+ private pendingLaunches = 0;
205
+ private readonly pendingLaunchesByProject = new Map<string, number>();
171
206
 
172
207
  constructor(options: IBrowserRuntimeOptions | IBrowserRuntimeTestingOptions) {
173
208
  if (!options || typeof options !== 'object') {
@@ -182,6 +217,9 @@ export class BrowserRuntime {
182
217
  if (options.audit !== undefined && typeof options.audit !== 'function') {
183
218
  throw new BrowserRuntimeError('INVALID_INPUT', 'audit must be a function');
184
219
  }
220
+ if (options.beforeOperation !== undefined && typeof options.beforeOperation !== 'function') {
221
+ throw new BrowserRuntimeError('INVALID_INPUT', 'beforeOperation must be a function');
222
+ }
185
223
  const testingOptions = browserRuntimeTesting in options
186
224
  && options[browserRuntimeTesting] === true
187
225
  ? options as IBrowserRuntimeTestingOptions
@@ -214,19 +252,41 @@ export class BrowserRuntime {
214
252
  this.options = {
215
253
  runtimeDirectory: options.runtimeDirectory,
216
254
  authorizeCapability: options.authorizeCapability,
255
+ beforeOperation: options.beforeOperation,
217
256
  audit: options.audit,
218
257
  sessionFactory: testingOptions?.sessionFactory ?? ((sessionOptions) => (
219
258
  new plugins.smartpuppeteer.LiveBrowserSession(sessionOptions)
220
259
  )),
221
260
  confinementProbe: testingOptions?.confinementProbe ?? runProductionConfinementProbe,
222
261
  environment,
223
- maxProjectSlots: validateOptionalInteger(
224
- options.maxProjectSlots,
225
- 'maxProjectSlots',
262
+ maxResources: validateOptionalInteger(
263
+ options.maxResources,
264
+ 'maxResources',
265
+ 1,
266
+ 4096,
267
+ 256,
268
+ ),
269
+ maxResourcesPerProject: validateOptionalInteger(
270
+ options.maxResourcesPerProject,
271
+ 'maxResourcesPerProject',
272
+ 1,
273
+ 1024,
274
+ 64,
275
+ ),
276
+ maxRunningResources: validateOptionalInteger(
277
+ options.maxRunningResources,
278
+ 'maxRunningResources',
226
279
  1,
227
280
  256,
228
281
  32,
229
282
  ),
283
+ maxRunningResourcesPerProject: validateOptionalInteger(
284
+ options.maxRunningResourcesPerProject,
285
+ 'maxRunningResourcesPerProject',
286
+ 1,
287
+ 128,
288
+ 16,
289
+ ),
230
290
  maxCapabilities: validateOptionalInteger(
231
291
  options.maxCapabilities,
232
292
  'maxCapabilities',
@@ -234,6 +294,13 @@ export class BrowserRuntime {
234
294
  4096,
235
295
  512,
236
296
  ),
297
+ maxCapabilitiesPerResource: validateOptionalInteger(
298
+ options.maxCapabilitiesPerResource,
299
+ 'maxCapabilitiesPerResource',
300
+ 1,
301
+ 512,
302
+ 64,
303
+ ),
237
304
  maxCapabilitiesPerProject: validateOptionalInteger(
238
305
  options.maxCapabilitiesPerProject,
239
306
  'maxCapabilitiesPerProject',
@@ -250,9 +317,16 @@ export class BrowserRuntime {
250
317
  120_000,
251
318
  10_000,
252
319
  ),
253
- maxTabsPerProject: validateOptionalInteger(
254
- options.maxTabsPerProject,
255
- 'maxTabsPerProject',
320
+ beforeOperationTimeoutMs: validateOptionalInteger(
321
+ options.beforeOperationTimeoutMs,
322
+ 'beforeOperationTimeoutMs',
323
+ 100,
324
+ 120_000,
325
+ 10_000,
326
+ ),
327
+ maxTabsPerResource: validateOptionalInteger(
328
+ options.maxTabsPerResource,
329
+ 'maxTabsPerResource',
256
330
  1,
257
331
  256,
258
332
  32,
@@ -299,9 +373,9 @@ export class BrowserRuntime {
299
373
  60_000,
300
374
  10_000,
301
375
  ),
302
- idleRetirementMs: validateOptionalInteger(
303
- options.idleRetirementMs,
304
- 'idleRetirementMs',
376
+ idleTerminationMs: validateOptionalInteger(
377
+ options.idleTerminationMs,
378
+ 'idleTerminationMs',
305
379
  1000,
306
380
  24 * 60 * 60 * 1000,
307
381
  15 * 60_000,
@@ -321,6 +395,11 @@ export class BrowserRuntime {
321
395
  10_000,
322
396
  ),
323
397
  egress: options.egress ?? {},
398
+ beforeLeasePublication: testingOptions?.beforeLeasePublication,
399
+ beforeIdleTermination: testingOptions?.beforeIdleTermination,
400
+ beforeFrameFailureTermination: testingOptions?.beforeFrameFailureTermination,
401
+ afterFrameFailureFence: testingOptions?.afterFrameFailureFence,
402
+ beforeFramedPeerLeasePublication: testingOptions?.beforeFramedPeerLeasePublication,
324
403
  };
325
404
  this.profileRoot = plugins.path.join(options.runtimeDirectory, 'profiles');
326
405
  this.artifactRoot = plugins.path.join(options.runtimeDirectory, 'artifacts');
@@ -373,21 +452,174 @@ export class BrowserRuntime {
373
452
  return this.stopPromise;
374
453
  }
375
454
 
455
+ public createResource(requestArg: ICreateBrowserResourceRequest): IBrowserResourceRegistration {
456
+ this.requireRunning();
457
+ if (!requestArg || typeof requestArg !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
458
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
459
+ const attachmentBinding = this.validateAttachmentBinding(requestArg.attachmentBinding);
460
+ let browserResourceId: string;
461
+ do {
462
+ browserResourceId = randomId(24);
463
+ } while (this.slots.has(this.resourceKey(projectId, browserResourceId)));
464
+ return this.registerResource({ projectId, browserResourceId, attachmentBinding });
465
+ }
466
+
467
+ public registerResource(
468
+ requestArg: IRegisterBrowserResourceRequest,
469
+ ): IBrowserResourceRegistration {
470
+ this.requireRunning();
471
+ if (!requestArg || typeof requestArg !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
472
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
473
+ const browserResourceId = this.validateResourceId(requestArg.browserResourceId);
474
+ const attachmentBinding = this.validateAttachmentBinding(requestArg.attachmentBinding);
475
+ const key = this.resourceKey(projectId, browserResourceId);
476
+ if (this.hasRetiredTombstone(this.retiredResourceIds, browserResourceId)) {
477
+ throw new BrowserRuntimeError('RESOURCE_RETIRED');
478
+ }
479
+ const existing = this.slots.get(key);
480
+ if (existing) {
481
+ if (!this.bindingsEqual(existing.attachmentBinding, attachmentBinding)) {
482
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
483
+ }
484
+ return this.describeResource(existing);
485
+ }
486
+ if ([...this.slots.values()].some((slot) => (
487
+ slot.browserResourceId === browserResourceId && slot.projectId !== projectId
488
+ ))) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
489
+ const projectResources = [...this.slots.values()].filter((slot) => (
490
+ slot.projectId === projectId
491
+ )).length;
492
+ if (
493
+ this.slots.size >= this.options.maxResources
494
+ || projectResources >= this.options.maxResourcesPerProject
495
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
496
+ const slot: IResourceSlot = {
497
+ projectId,
498
+ browserResourceId,
499
+ registeredAt: Date.now(),
500
+ attachmentBinding,
501
+ mutex: new TransitionMutex(),
502
+ arbitrationGeneration: 0,
503
+ incarnationGeneration: 0,
504
+ permanentlyFenced: false,
505
+ retirementPending: false,
506
+ attachmentFenceCount: 0,
507
+ lifecycleTail: Promise.resolve(),
508
+ lifecycleOperationCount: 0,
509
+ terminationRequestGeneration: 0,
510
+ attachmentOperations: new Map(),
511
+ };
512
+ this.slots.set(key, slot);
513
+ return this.describeResource(slot);
514
+ }
515
+
516
+ public listResources(): IBrowserResourceRegistration[] {
517
+ this.requireRunning();
518
+ return [...this.slots.values()].map((slot) => this.describeResource(slot));
519
+ }
520
+
521
+ public async applyAttachmentBinding(
522
+ requestArg: IApplyBrowserAttachmentBindingRequest,
523
+ ): Promise<IBrowserResourceRegistration> {
524
+ this.requireRunning();
525
+ const slot = this.resolveResourceRequest(requestArg);
526
+ const binding = this.validateAttachmentBinding(requestArg.attachmentBinding);
527
+ const operationKey = this.attachmentBindingKey(binding);
528
+ const existingOperation = slot.attachmentOperations.get(operationKey);
529
+ if (existingOperation) return existingOperation;
530
+ if (slot.attachmentOperations.size > 0) throw new BrowserRuntimeError('BUSY');
531
+ if (
532
+ slot.attachmentRetryBinding
533
+ && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
534
+ ) throw new BrowserRuntimeError('BUSY');
535
+ const currentBinding = slot.attachmentBinding;
536
+ if (binding.attachmentRevision < currentBinding.attachmentRevision) {
537
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
538
+ }
539
+ if (
540
+ binding.attachmentRevision === currentBinding.attachmentRevision
541
+ && !slot.attachmentRetryBinding
542
+ ) {
543
+ if (!this.bindingsEqual(binding, currentBinding)) {
544
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
545
+ }
546
+ return this.describeResource(slot);
547
+ }
548
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
549
+ slot.attachmentFenceCount += 1;
550
+ const operation = this.enqueueResourceLifecycle(slot, async () => (
551
+ this.applyAttachmentBindingInternal(slot, binding)
552
+ )).finally(() => {
553
+ slot.attachmentFenceCount = Math.max(0, slot.attachmentFenceCount - 1);
554
+ slot.attachmentOperations.delete(operationKey);
555
+ });
556
+ slot.attachmentOperations.set(operationKey, operation);
557
+ return operation;
558
+ }
559
+
560
+ public terminateResource(projectIdArg: string, browserResourceIdArg: string): Promise<void> {
561
+ this.requireRunning();
562
+ const slot = this.getRegisteredSlot(projectIdArg, browserResourceIdArg);
563
+ if (slot.retirePromise) return slot.retirePromise;
564
+ if (slot.terminatePromise) return slot.terminatePromise;
565
+ const terminationFenceGeneration = slot.terminationFenceGeneration
566
+ ?? ++slot.terminationRequestGeneration;
567
+ slot.terminationFenceGeneration = terminationFenceGeneration;
568
+ const lifecycleOperation = this.enqueueResourceLifecycle(slot, async () => {
569
+ await this.terminateRegisteredResourceInternal(slot, false);
570
+ });
571
+ const operation = lifecycleOperation.then(() => {
572
+ if (slot.terminationFenceGeneration === terminationFenceGeneration) {
573
+ slot.terminationFenceGeneration = undefined;
574
+ }
575
+ }).finally(() => {
576
+ if (slot.terminatePromise === operation) slot.terminatePromise = undefined;
577
+ });
578
+ slot.terminatePromise = operation;
579
+ return operation;
580
+ }
581
+
582
+ public retireResource(projectIdArg: string, browserResourceIdArg: string): Promise<void> {
583
+ this.requireRunning();
584
+ const slot = this.getRegisteredSlot(projectIdArg, browserResourceIdArg);
585
+ if (slot.retirePromise) return slot.retirePromise;
586
+ slot.retirementPending = true;
587
+ this.addRetiredTombstone(this.retiredResourceKeys, this.resourceKey(
588
+ slot.projectId,
589
+ slot.browserResourceId,
590
+ ));
591
+ this.addRetiredTombstone(this.retiredResourceIds, slot.browserResourceId);
592
+ const operation = this.enqueueResourceLifecycle(slot, async () => {
593
+ await this.retireRegisteredResourceInternal(slot);
594
+ }).finally(() => {
595
+ if (slot.retirePromise === operation) slot.retirePromise = undefined;
596
+ });
597
+ slot.retirePromise = operation;
598
+ return operation;
599
+ }
600
+
376
601
  public async issueCapability(
377
- requestArg: IIssueBrowserCapabilityRequest,
378
- ): Promise<IIssuedBrowserCapability> {
602
+ requestArg: TIssueBrowserCapabilityRequest,
603
+ ): Promise<TIssuedBrowserCapability> {
379
604
  this.requireRunning();
380
605
  if (!requestArg || typeof requestArg !== 'object') {
381
606
  throw new BrowserRuntimeError('INVALID_INPUT');
382
607
  }
383
- const projectId = validateBoundedString(
384
- requestArg.projectId,
385
- 'projectId',
386
- 1,
387
- projectIdMaximum,
608
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
609
+ const browserResourceId = this.validateResourceId(requestArg.browserResourceId);
610
+ const slot = this.getRegisteredSlot(projectId, browserResourceId);
611
+ const attachmentAuthorityId = this.validateIdentifier(
612
+ requestArg.attachmentAuthorityId,
613
+ 'attachmentAuthorityId',
614
+ );
615
+ const attachmentRevision = validateInteger(
616
+ requestArg.attachmentRevision,
617
+ 'attachmentRevision',
618
+ 0,
619
+ Number.MAX_SAFE_INTEGER,
388
620
  );
389
- const actorId = validateBoundedString(requestArg.actorId, 'actorId', 1, actorIdMaximum);
390
- const peerId = validateBoundedString(requestArg.peerId, 'peerId', 1, peerIdMaximum);
621
+ const actorId = this.validateIdentifier(requestArg.actorId, 'actorId');
622
+ const peerId = this.validateIdentifier(requestArg.peerId, 'peerId');
391
623
  const role = this.validateRole(requestArg.role);
392
624
  const source = this.validateSource(requestArg.source);
393
625
  if (
@@ -402,14 +634,41 @@ export class BrowserRuntime {
402
634
  const disconnect = requestArg.disconnect;
403
635
  const scopeId = requestArg.scopeId === undefined
404
636
  ? undefined
405
- : validateBoundedString(requestArg.scopeId, 'scopeId', 1, 128);
637
+ : this.validateIdentifier(requestArg.scopeId, 'scopeId');
638
+ const channelId = requestArg.channelId === undefined
639
+ ? undefined
640
+ : this.validateIdentifier(requestArg.channelId, 'channelId');
406
641
  const sessionId = requestArg.sessionId === undefined
407
642
  ? undefined
408
- : validateBoundedString(requestArg.sessionId, 'sessionId', 1, 128);
643
+ : this.validateQualifiedSessionId(requestArg.sessionId);
409
644
  if (
410
- (source === 'flex' && (!scopeId || !sessionId))
411
- || (source !== 'flex' && (scopeId !== undefined || sessionId !== undefined))
645
+ (role === 'agent' && !sessionId)
646
+ || (role === 'human' && sessionId !== undefined)
647
+ || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
648
+ || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
649
+ || sessionId?.harnessId !== 'opencode'))
650
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
412
651
  ) throw new BrowserRuntimeError('INVALID_INPUT');
652
+ const authority = this.validateExpectedCapabilityBinding({
653
+ projectId,
654
+ browserResourceId,
655
+ attachmentAuthorityId,
656
+ attachmentRevision,
657
+ actorId,
658
+ role,
659
+ peerId,
660
+ source,
661
+ ...(scopeId ? { scopeId } : {}),
662
+ ...(channelId ? { channelId } : {}),
663
+ ...(sessionId ? { sessionId } : {}),
664
+ } as TBrowserCapabilityAuthorizationRequest);
665
+ this.assertCapabilityBinding(slot, {
666
+ attachmentAuthorityId,
667
+ attachmentRevision,
668
+ role,
669
+ sessionId,
670
+ });
671
+ this.assertSlotAvailable(slot);
413
672
  const expiresInMs = requestArg.expiresInMs === undefined
414
673
  ? this.options.capabilityDefaultTtlMs
415
674
  : validateInteger(
@@ -420,16 +679,21 @@ export class BrowserRuntime {
420
679
  );
421
680
 
422
681
  const lifecycleEpoch = this.lifecycleEpoch;
682
+ const resourceKey = this.resourceKey(projectId, browserResourceId);
423
683
  const pendingForProject = this.pendingCapabilitiesByProject.get(projectId) ?? 0;
424
- const activeForProject = [...this.capabilitiesById.values()].filter((record) => (
425
- record.projectId === projectId && record.state === 'active'
684
+ const pendingForResource = this.pendingCapabilitiesByResource.get(resourceKey) ?? 0;
685
+ const recordedForProject = [...this.capabilitiesById.values()].filter((record) => (
686
+ record.projectId === projectId
426
687
  )).length;
688
+ const recordedForResource = this.capabilitiesForResource(slot).length;
427
689
  if (
428
690
  this.pendingCapabilities + this.capabilitiesById.size >= this.options.maxCapabilities
429
- || pendingForProject + activeForProject >= this.options.maxCapabilitiesPerProject
691
+ || pendingForProject + recordedForProject >= this.options.maxCapabilitiesPerProject
692
+ || pendingForResource + recordedForResource >= this.options.maxCapabilitiesPerResource
430
693
  ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
431
694
  this.pendingCapabilities += 1;
432
695
  this.pendingCapabilitiesByProject.set(projectId, pendingForProject + 1);
696
+ this.pendingCapabilitiesByResource.set(resourceKey, pendingForResource + 1);
433
697
  let authorized = false;
434
698
  const authorizationController = new AbortController();
435
699
  const authorizationSignal = AbortSignal.any([
@@ -437,15 +701,10 @@ export class BrowserRuntime {
437
701
  this.lifecycleController.signal,
438
702
  ]);
439
703
  try {
440
- const authorization = Promise.resolve(this.options.authorizeCapability({
441
- projectId,
442
- actorId,
443
- role,
444
- peerId,
445
- source,
446
- ...(scopeId ? { scopeId } : {}),
447
- ...(sessionId ? { sessionId } : {}),
448
- }, authorizationSignal));
704
+ const authorization = Promise.resolve(this.options.authorizeCapability(
705
+ authority,
706
+ authorizationSignal,
707
+ ));
449
708
  const result = await waitBounded(authorization, this.options.authorizationTimeoutMs);
450
709
  if (!result.settled) {
451
710
  authorizationController.abort(new BrowserRuntimeError('TIMEOUT'));
@@ -460,25 +719,48 @@ export class BrowserRuntime {
460
719
  const remaining = (this.pendingCapabilitiesByProject.get(projectId) ?? 1) - 1;
461
720
  if (remaining > 0) this.pendingCapabilitiesByProject.set(projectId, remaining);
462
721
  else this.pendingCapabilitiesByProject.delete(projectId);
722
+ const resourceRemaining = (this.pendingCapabilitiesByResource.get(resourceKey) ?? 1) - 1;
723
+ if (resourceRemaining > 0) {
724
+ this.pendingCapabilitiesByResource.set(resourceKey, resourceRemaining);
725
+ } else {
726
+ this.pendingCapabilitiesByResource.delete(resourceKey);
727
+ }
463
728
  }
464
729
  if (authorized !== true) throw new BrowserRuntimeError('AUTHORIZATION_DENIED');
465
730
  this.requireRunning();
466
731
  if (this.lifecycleEpoch !== lifecycleEpoch) throw new BrowserRuntimeError('ABORTED');
732
+ const slotAfterAuthorization = this.getRegisteredSlot(projectId, browserResourceId);
733
+ this.assertCapabilityBinding(slotAfterAuthorization, {
734
+ attachmentAuthorityId,
735
+ attachmentRevision,
736
+ role,
737
+ sessionId,
738
+ });
739
+ this.assertSlotAvailable(slotAfterAuthorization);
467
740
 
468
741
  const release = await this.capabilityMutex.acquire();
469
742
  try {
470
743
  this.requireRunning();
471
744
  if (this.lifecycleEpoch !== lifecycleEpoch) throw new BrowserRuntimeError('ABORTED');
745
+ const currentSlot = this.getRegisteredSlot(projectId, browserResourceId);
746
+ this.assertCapabilityBinding(currentSlot, {
747
+ attachmentAuthorityId,
748
+ attachmentRevision,
749
+ role,
750
+ sessionId,
751
+ });
752
+ this.assertSlotAvailable(currentSlot);
472
753
  const activeProjectCapabilities = [...this.capabilitiesById.values()].filter((record) => (
473
- record.projectId === projectId && record.state === 'active'
754
+ record.projectId === projectId
474
755
  )).length;
756
+ const activeResourceCapabilities = this.capabilitiesForResource(currentSlot).length;
475
757
  if (
476
758
  this.capabilitiesById.size >= this.options.maxCapabilities
477
759
  || activeProjectCapabilities >= this.options.maxCapabilitiesPerProject
760
+ || activeResourceCapabilities >= this.options.maxCapabilitiesPerResource
478
761
  ) {
479
762
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
480
763
  }
481
- this.getOrCreateSlot(projectId);
482
764
  let capabilityToken: string;
483
765
  let digest: plugins.Buffer;
484
766
  let digestKey: string;
@@ -489,20 +771,14 @@ export class BrowserRuntime {
489
771
  } while (this.capabilitiesByDigest.has(digestKey));
490
772
  const capabilityId = randomId(18);
491
773
  const expiresAt = Date.now() + expiresInMs;
492
- let record: ICapabilityRecord;
774
+ let record: TCapabilityRecord;
493
775
  const expiryTimer = setTimeout(() => {
494
776
  if (record.state === 'active') record.state = 'expired';
495
777
  this.trackCleanup(this.revokeCapabilityRecord(record));
496
778
  }, expiresInMs);
497
779
  record = {
780
+ ...authority,
498
781
  capabilityId,
499
- projectId,
500
- actorId,
501
- role,
502
- peerId,
503
- source,
504
- ...(scopeId ? { scopeId } : {}),
505
- ...(sessionId ? { sessionId } : {}),
506
782
  expiresAt,
507
783
  digest,
508
784
  state: 'active',
@@ -513,14 +789,8 @@ export class BrowserRuntime {
513
789
  this.capabilitiesByDigest.set(digestKey, record);
514
790
  this.capabilitiesById.set(capabilityId, record);
515
791
  return {
792
+ ...authority,
516
793
  capabilityId,
517
- projectId,
518
- actorId,
519
- role,
520
- peerId,
521
- source,
522
- ...(scopeId ? { scopeId } : {}),
523
- ...(sessionId ? { sessionId } : {}),
524
794
  expiresAt,
525
795
  capabilityToken,
526
796
  };
@@ -530,25 +800,13 @@ export class BrowserRuntime {
530
800
  }
531
801
 
532
802
  public async acquireLease(
533
- optionsArg: IAcquireBrowserRuntimeLeaseOptions,
803
+ optionsArg: TAcquireBrowserRuntimeLeaseOptions,
534
804
  ): Promise<BrowserRuntimeLease> {
535
805
  this.requireRunning();
536
806
  if (!optionsArg || typeof optionsArg !== 'object') {
537
807
  throw new BrowserRuntimeError('INVALID_INPUT');
538
808
  }
539
- const peerId = validateBoundedString(optionsArg.peerId, 'peerId', 1, peerIdMaximum);
540
- const expectedRole = optionsArg.expectedRole === undefined
541
- ? undefined
542
- : this.validateRole(optionsArg.expectedRole);
543
- const expectedSource = optionsArg.expectedSource === undefined
544
- ? undefined
545
- : this.validateSource(optionsArg.expectedSource);
546
- const scopeId = optionsArg.scopeId === undefined
547
- ? undefined
548
- : validateBoundedString(optionsArg.scopeId, 'scopeId', 1, 128);
549
- const sessionId = optionsArg.sessionId === undefined
550
- ? undefined
551
- : validateBoundedString(optionsArg.sessionId, 'sessionId', 1, 128);
809
+ const expected = this.validateExpectedCapabilityBinding(optionsArg);
552
810
  this.throwIfAcquisitionAborted(optionsArg.signal);
553
811
  const capabilityToken = validateBoundedString(
554
812
  optionsArg.capabilityToken,
@@ -559,13 +817,10 @@ export class BrowserRuntime {
559
817
  const digest = digestToken(capabilityToken);
560
818
  const capability = this.resolveCapability(
561
819
  digest,
562
- peerId,
563
- expectedRole,
564
- expectedSource,
565
- scopeId,
566
- sessionId,
820
+ expected,
567
821
  );
568
- const slot = this.getOrCreateSlot(capability.projectId);
822
+ const slot = this.getRegisteredSlot(capability.projectId, capability.browserResourceId);
823
+ this.assertCapabilityStillAttached(capability, slot);
569
824
  return capability.role === 'agent'
570
825
  ? this.acquireAgentLease(slot, capability, digest, optionsArg)
571
826
  : this.acquireHumanLease(slot, capability, digest, optionsArg);
@@ -573,14 +828,16 @@ export class BrowserRuntime {
573
828
 
574
829
  public authorizeCapability(
575
830
  capabilityTokenArg: string,
576
- peerIdArg: string,
577
- role: TBrowserActorRole,
578
- source: TBrowserCapabilitySource,
579
- ): IBrowserCapabilityDescriptor {
831
+ expectedArg: TBrowserCapabilityAuthorizationRequest,
832
+ ): TBrowserCapabilityDescriptor {
580
833
  this.requireRunning();
581
834
  const token = validateBoundedString(capabilityTokenArg, 'capabilityToken', 16, 512);
582
- const peerId = validateBoundedString(peerIdArg, 'peerId', 1, peerIdMaximum);
583
- const record = this.resolveCapability(digestToken(token), peerId, role, source);
835
+ const expected = this.validateExpectedCapabilityBinding(expectedArg);
836
+ const record = this.resolveCapability(digestToken(token), expected);
837
+ this.assertCapabilityStillAttached(
838
+ record,
839
+ this.getRegisteredSlot(record.projectId, record.browserResourceId),
840
+ );
584
841
  return this.describeCapability(record);
585
842
  }
586
843
 
@@ -596,8 +853,22 @@ export class BrowserRuntime {
596
853
  await this.revokeCapabilityRecord(capability);
597
854
  }
598
855
 
856
+ /** @internal */
857
+ public async revokeFramedCapability(capabilityIdArg: string): Promise<void> {
858
+ const capabilityId = validateBoundedString(capabilityIdArg, 'capabilityId', 1, 128);
859
+ const capability = this.capabilitiesById.get(capabilityId);
860
+ if (!capability) return;
861
+ if (capability.source !== 'flex') throw new BrowserRuntimeError('CAPABILITY_INVALID');
862
+ await this.revokeCapabilityRecord(capability, false);
863
+ }
864
+
865
+ /** @internal */
866
+ public async beforeFramedPeerLeasePublication(): Promise<void> {
867
+ await this.options.beforeFramedPeerLeasePublication?.();
868
+ }
869
+
599
870
  public async disconnectPeer(peerIdArg: string): Promise<void> {
600
- const peerId = validateBoundedString(peerIdArg, 'peerId', 1, peerIdMaximum);
871
+ const peerId = this.validateIdentifier(peerIdArg, 'peerId');
601
872
  const matching = [...this.capabilitiesById.values()].filter((record) => (
602
873
  record.peerId === peerId && record.state === 'active'
603
874
  ));
@@ -610,27 +881,29 @@ export class BrowserRuntime {
610
881
  if (errors.length > 0) throw new AggregateError(errors, 'Peer cleanup is incomplete');
611
882
  }
612
883
 
613
- public async retireProject(projectIdArg: string): Promise<void> {
614
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, projectIdMaximum);
615
- const capabilities = [...this.capabilitiesById.values()].filter((record) => (
616
- record.projectId === projectId
617
- ));
618
- const results = await Promise.allSettled(
619
- capabilities.map((record) => this.revokeCapabilityRecord(record)),
620
- );
621
- const errors = results
622
- .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
623
- .map((result) => result.reason);
624
- if (errors.length > 0) throw new AggregateError(errors, 'Project capability cleanup is incomplete');
625
- await this.retireSlot(projectId, true);
626
- }
627
-
628
884
  public attachTrustedFramedPeer(
629
885
  options: IAttachTrustedFramedPeerOptions,
630
886
  ): BrowserRuntimeFramedServerPeer {
631
887
  this.requireRunning();
632
- const peerId = validateBoundedString(options.peerId, 'peerId', 1, peerIdMaximum);
633
- if ([...this.framedPeers].some((peer) => peer.peerId === peerId)) {
888
+ const trustedBinding = this.validateExpectedCapabilityBinding(options);
889
+ if (trustedBinding.source !== 'flex') throw new BrowserRuntimeError('INVALID_INPUT');
890
+ const slot = this.getRegisteredSlot(
891
+ trustedBinding.projectId,
892
+ trustedBinding.browserResourceId,
893
+ );
894
+ this.assertCapabilityBinding(slot, trustedBinding);
895
+ this.assertSlotAvailable(slot);
896
+ if (
897
+ slot.lifecycleOperationCount > 0
898
+ || slot.terminatePromise
899
+ || slot.retirePromise
900
+ || slot.attachmentOperations.size > 0
901
+ ) throw new BrowserRuntimeError('BUSY');
902
+ const peerId = trustedBinding.peerId;
903
+ const channelId = trustedBinding.channelId;
904
+ if ([...this.framedPeers].some((peer) => (
905
+ peer.peerId === peerId && peer.channelId === channelId
906
+ ))) {
634
907
  throw new BrowserRuntimeError('BUSY');
635
908
  }
636
909
  const peer = new BrowserRuntimeFramedServerPeer(this, options, () => {
@@ -649,7 +922,7 @@ export class BrowserRuntime {
649
922
  public getLeaseState(lease: ILeaseRecord): IBrowserRuntimeState {
650
923
  this.requireValidLease(lease);
651
924
  const session = lease.slot.session!;
652
- return this.projectState(session.getState());
925
+ return this.resourceState(session.getState());
653
926
  }
654
927
 
655
928
  /** @internal */
@@ -673,11 +946,11 @@ export class BrowserRuntime {
673
946
  ): Promise<IBrowserRuntimeState> {
674
947
  this.requireHuman(lease);
675
948
  return this.runOperation(lease, 'createTab', operationOptions, async (signal, session) => {
676
- if (session.getState().tabs.length >= this.options.maxTabsPerProject) {
949
+ if (session.getState().tabs.length >= this.options.maxTabsPerResource) {
677
950
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
678
951
  }
679
952
  await session.createTab(options, { signal });
680
- return this.projectState(session.getState());
953
+ return this.resourceState(session.getState());
681
954
  });
682
955
  }
683
956
 
@@ -691,7 +964,7 @@ export class BrowserRuntime {
691
964
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
692
965
  return this.runOperation(lease, 'activateTab', operationOptions, async (signal, session) => {
693
966
  await session.activateTab(tabId, { signal });
694
- return this.projectState(session.getState());
967
+ return this.resourceState(session.getState());
695
968
  });
696
969
  }
697
970
 
@@ -705,7 +978,7 @@ export class BrowserRuntime {
705
978
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
706
979
  return this.runOperation(lease, 'closeTab', operationOptions, async (signal, session) => {
707
980
  await session.closeTab(tabId, { signal });
708
- return this.projectState(session.getState());
981
+ return this.resourceState(session.getState());
709
982
  });
710
983
  }
711
984
 
@@ -719,7 +992,7 @@ export class BrowserRuntime {
719
992
  this.requireHuman(lease);
720
993
  return this.runOperation(lease, action, operationOptions, async (signal, session) => {
721
994
  await session[action](options, { signal });
722
- return this.projectState(session.getState());
995
+ return this.resourceState(session.getState());
723
996
  });
724
997
  }
725
998
 
@@ -790,7 +1063,7 @@ export class BrowserRuntime {
790
1063
  slot.frameSubscription = subscription;
791
1064
  this.pushToSubscription(subscription, {
792
1065
  type: 'state',
793
- state: this.projectState(slot.session!.getState()),
1066
+ state: this.resourceState(slot.session!.getState()),
794
1067
  });
795
1068
  } finally {
796
1069
  release();
@@ -832,20 +1105,14 @@ export class BrowserRuntime {
832
1105
  clearTimeout(subscription.outstanding.timer);
833
1106
  subscription.outstanding = undefined;
834
1107
  const session = slot.session!;
1108
+ const incarnationGeneration = slot.incarnationGeneration;
835
1109
  const operation = session.acknowledgeFrame(acknowledgement);
836
1110
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
837
1111
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
838
1112
  if (!result.settled || !result.value.accepted) {
839
1113
  operation.catch(() => undefined);
840
- const errors: unknown[] = [];
841
- await this.revokeCapabilityRecord(lease.capability).catch((error) => errors.push(error));
842
- if (slot.session === session) {
843
- await this.terminateSlotSession(slot).catch((error) => errors.push(error));
844
- }
845
- if (errors.length === 1) throw errors[0];
846
- if (errors.length > 1) {
847
- throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
848
- }
1114
+ await this.options.beforeFrameFailureTermination?.();
1115
+ await this.handleFrameFailure(slot, session, incarnationGeneration, lease);
849
1116
  return false;
850
1117
  }
851
1118
  return true;
@@ -858,14 +1125,22 @@ export class BrowserRuntime {
858
1125
  ): Promise<Uint8Array> {
859
1126
  this.requireHuman(lease);
860
1127
  this.requireValidLease(lease);
861
- return this.artifactStore.read(lease.capability.projectId, artifactId);
1128
+ return this.artifactStore.read(
1129
+ lease.capability.projectId,
1130
+ lease.capability.browserResourceId,
1131
+ artifactId,
1132
+ );
862
1133
  }
863
1134
 
864
1135
  /** @internal */
865
1136
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
866
1137
  this.requireHuman(lease);
867
1138
  this.requireValidLease(lease);
868
- await this.artifactStore.delete(lease.capability.projectId, artifactId);
1139
+ await this.artifactStore.delete(
1140
+ lease.capability.projectId,
1141
+ lease.capability.browserResourceId,
1142
+ artifactId,
1143
+ );
869
1144
  }
870
1145
 
871
1146
  /** @internal */
@@ -924,7 +1199,9 @@ export class BrowserRuntime {
924
1199
  [...revocationRecords].map((record) => this.revokeCapabilityRecord(record)),
925
1200
  );
926
1201
  const retirementResults = await Promise.allSettled(
927
- [...this.slots.keys()].map((projectId) => this.retireSlot(projectId, true)),
1202
+ [...this.slots.values()].map((slot) => this.enqueueResourceLifecycle(slot, async () => {
1203
+ await this.terminateRegisteredResourceInternal(slot, true);
1204
+ })),
928
1205
  );
929
1206
  while (this.cleanupOperations.size > 0) {
930
1207
  await Promise.allSettled([...this.cleanupOperations]);
@@ -945,6 +1222,9 @@ export class BrowserRuntime {
945
1222
  this.lockHandle = undefined;
946
1223
  this.lockHandleClosed = false;
947
1224
  }
1225
+ this.slots.clear();
1226
+ this.retiredResourceIds.fill(0);
1227
+ this.retiredResourceKeys.fill(0);
948
1228
  this.startPromise = undefined;
949
1229
  }
950
1230
 
@@ -952,55 +1232,29 @@ export class BrowserRuntime {
952
1232
  if (this.lifecycleState !== 'running') throw new BrowserRuntimeError('NOT_RUNNING');
953
1233
  }
954
1234
 
955
- private getOrCreateSlot(projectId: string): IProjectSlot {
956
- let slot = this.slots.get(projectId);
957
- if (slot) return slot;
958
- if (this.slots.size >= this.options.maxProjectSlots) {
959
- throw new BrowserRuntimeError('QUOTA_EXCEEDED');
960
- }
961
- slot = {
962
- projectId,
963
- mutex: new TransitionMutex(),
964
- generation: 0,
965
- permanentlyFenced: false,
966
- };
967
- this.slots.set(projectId, slot);
968
- return slot;
969
- }
970
-
971
1235
  private async acquireAgentLease(
972
- slot: IProjectSlot,
973
- capability: ICapabilityRecord,
1236
+ slot: IResourceSlot,
1237
+ capability: TCapabilityRecord,
974
1238
  digest: plugins.Buffer,
975
- acquisition: IAcquireBrowserRuntimeLeaseOptions,
1239
+ acquisition: TAcquireBrowserRuntimeLeaseOptions,
976
1240
  ): Promise<BrowserRuntimeLease> {
977
1241
  const release = slot.mutex.tryAcquire();
978
1242
  if (!release) throw new BrowserRuntimeError('BUSY');
979
1243
  try {
980
1244
  this.assertSlotAvailable(slot);
981
- this.resolveCapability(
982
- digest,
983
- capability.peerId,
984
- acquisition.expectedRole ?? 'agent',
985
- acquisition.expectedSource,
986
- acquisition.scopeId,
987
- acquisition.sessionId,
988
- );
1245
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1246
+ this.assertCapabilityStillAttached(capability, slot);
989
1247
  if (slot.lease || slot.operation) throw new BrowserRuntimeError('BUSY');
990
1248
  await this.ensureSession(slot, acquisition.signal);
1249
+ await this.options.beforeLeasePublication?.();
991
1250
  this.throwIfAcquisitionAborted(acquisition.signal);
992
- this.resolveCapability(
993
- digest,
994
- capability.peerId,
995
- acquisition.expectedRole ?? 'agent',
996
- acquisition.expectedSource,
997
- acquisition.scopeId,
998
- acquisition.sessionId,
999
- );
1251
+ this.assertSlotAvailable(slot);
1252
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1253
+ this.assertCapabilityStillAttached(capability, slot);
1000
1254
  const lease = this.createLeaseRecord(slot, capability, digest);
1001
1255
  return new BrowserRuntimeLease(this, lease);
1002
1256
  } catch (error) {
1003
- if (slot.session && !slot.lease) this.scheduleIdleRetirement(slot);
1257
+ if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1004
1258
  throw error;
1005
1259
  } finally {
1006
1260
  release();
@@ -1008,27 +1262,29 @@ export class BrowserRuntime {
1008
1262
  }
1009
1263
 
1010
1264
  private async acquireHumanLease(
1011
- slot: IProjectSlot,
1012
- capability: ICapabilityRecord,
1265
+ slot: IResourceSlot,
1266
+ capability: TCapabilityRecord,
1013
1267
  digest: plugins.Buffer,
1014
- acquisition: IAcquireBrowserRuntimeLeaseOptions,
1268
+ acquisition: TAcquireBrowserRuntimeLeaseOptions,
1015
1269
  ): Promise<BrowserRuntimeLease> {
1016
1270
  const release = await slot.mutex.acquire();
1017
1271
  let generation = 0;
1018
1272
  let preemptedLease: ILeaseRecord | undefined;
1019
1273
  try {
1020
1274
  this.assertSlotAvailable(slot);
1021
- this.resolveCapability(digest, capability.peerId, 'human', 'human');
1275
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1276
+ this.assertCapabilityStillAttached(capability, slot);
1022
1277
  this.throwIfAcquisitionAborted(acquisition.signal);
1023
1278
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1024
1279
  if (slot.lease?.role === 'human') throw new BrowserRuntimeError('BUSY');
1025
- generation = ++slot.generation;
1280
+ generation = ++slot.arbitrationGeneration;
1026
1281
  slot.fencingGeneration = generation;
1027
1282
  preemptedLease = slot.lease;
1028
1283
  if (preemptedLease) {
1029
1284
  preemptedLease.released = true;
1030
1285
  preemptedLease.controller.abort(new BrowserRuntimeError('ABORTED'));
1031
1286
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1287
+ this.notifyFramedLeaseEnded(preemptedLease);
1032
1288
  }
1033
1289
  } finally {
1034
1290
  release();
@@ -1039,7 +1295,7 @@ export class BrowserRuntime {
1039
1295
  this.throwIfAcquisitionAborted(acquisition.signal);
1040
1296
  const finalRelease = await slot.mutex.acquire();
1041
1297
  try {
1042
- if (slot.generation !== generation || slot.fencingGeneration !== generation) {
1298
+ if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1043
1299
  throw new BrowserRuntimeError('BUSY');
1044
1300
  }
1045
1301
  if (preemptedLease) {
@@ -1051,8 +1307,11 @@ export class BrowserRuntime {
1051
1307
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
1052
1308
  slot.fencingGeneration = undefined;
1053
1309
  await this.ensureSession(slot, acquisition.signal);
1310
+ await this.options.beforeLeasePublication?.();
1054
1311
  this.throwIfAcquisitionAborted(acquisition.signal);
1055
- this.resolveCapability(digest, capability.peerId, 'human', 'human');
1312
+ this.assertSlotAvailable(slot);
1313
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1314
+ this.assertCapabilityStillAttached(capability, slot);
1056
1315
  const lease = this.createLeaseRecord(slot, capability, digest);
1057
1316
  return new BrowserRuntimeLease(this, lease);
1058
1317
  } finally {
@@ -1068,7 +1327,7 @@ export class BrowserRuntime {
1068
1327
  if (slot.lease === preemptedLease) slot.lease = undefined;
1069
1328
  }
1070
1329
  if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
1071
- if (slot.session && !slot.lease) this.scheduleIdleRetirement(slot);
1330
+ if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1072
1331
  } finally {
1073
1332
  cleanupRelease();
1074
1333
  }
@@ -1077,10 +1336,12 @@ export class BrowserRuntime {
1077
1336
  }
1078
1337
 
1079
1338
  private createLeaseRecord(
1080
- slot: IProjectSlot,
1081
- capability: ICapabilityRecord,
1339
+ slot: IResourceSlot,
1340
+ capability: TCapabilityRecord,
1082
1341
  digest: plugins.Buffer,
1083
1342
  ): ILeaseRecord {
1343
+ this.assertSlotAvailable(slot);
1344
+ this.assertCapabilityStillAttached(capability, slot);
1084
1345
  if (capability.lease || slot.lease) throw new BrowserRuntimeError('BUSY');
1085
1346
  if (slot.idleTimer) {
1086
1347
  clearTimeout(slot.idleTimer);
@@ -1101,9 +1362,10 @@ export class BrowserRuntime {
1101
1362
  return lease;
1102
1363
  }
1103
1364
 
1104
- private async ensureSession(slot: IProjectSlot, signalArg?: AbortSignal): Promise<void> {
1365
+ private async ensureSession(slot: IResourceSlot, signalArg?: AbortSignal): Promise<void> {
1105
1366
  if (slot.session) return;
1106
1367
  this.assertSlotAvailable(slot);
1368
+ this.reserveLaunch(slot);
1107
1369
  const profileDirectory = plugins.path.join(this.profileRoot, randomId(24));
1108
1370
  let profileCreated = false;
1109
1371
  let proxy: BrowserEgressProxy | undefined;
@@ -1115,6 +1377,7 @@ export class BrowserRuntime {
1115
1377
  await plugins.fsPromises.chmod(profileDirectory, 0o700);
1116
1378
  proxy = new BrowserEgressProxy({
1117
1379
  projectId: slot.projectId,
1380
+ browserResourceId: slot.browserResourceId,
1118
1381
  ...this.options.egress,
1119
1382
  });
1120
1383
  await proxy.start();
@@ -1174,7 +1437,13 @@ export class BrowserRuntime {
1174
1437
  };
1175
1438
  try {
1176
1439
  await runBounded(session.start({ signal: probeSignal }));
1177
- await runBounded(this.options.confinementProbe({ session, proxy, signal: probeSignal }));
1440
+ await runBounded(this.options.confinementProbe({
1441
+ projectId: slot.projectId,
1442
+ browserResourceId: slot.browserResourceId,
1443
+ session,
1444
+ proxy,
1445
+ signal: probeSignal,
1446
+ }));
1178
1447
  } finally {
1179
1448
  clearTimeout(timeout);
1180
1449
  }
@@ -1183,6 +1452,7 @@ export class BrowserRuntime {
1183
1452
  slot.proxy = proxy;
1184
1453
  slot.profileDirectory = profileDirectory;
1185
1454
  slot.unsubscribeSession = unsubscribe;
1455
+ slot.incarnationGeneration += 1;
1186
1456
  } catch (error) {
1187
1457
  unsubscribe?.();
1188
1458
  let confirmed = true;
@@ -1228,6 +1498,8 @@ export class BrowserRuntime {
1228
1498
  throw error instanceof BrowserRuntimeError
1229
1499
  ? error
1230
1500
  : new BrowserRuntimeError('CONFINEMENT_FAILED');
1501
+ } finally {
1502
+ this.releaseLaunchReservation(slot);
1231
1503
  }
1232
1504
  }
1233
1505
 
@@ -1248,23 +1520,28 @@ export class BrowserRuntime {
1248
1520
  operationOptions.onOperationStarted !== undefined
1249
1521
  && typeof operationOptions.onOperationStarted !== 'function'
1250
1522
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1251
- operationOptions.signal?.throwIfAborted();
1523
+ if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1252
1524
  const slot = lease.slot;
1253
1525
  const release = slot.mutex.tryAcquire();
1254
1526
  if (!release) throw new BrowserRuntimeError('BUSY');
1255
1527
  let operation: IOperationRecord;
1256
- let executionPromise: Promise<T>;
1528
+ let executionPromise: Promise<T> | undefined;
1257
1529
  let combinedSignal: AbortSignal;
1258
1530
  let timeout: ReturnType<typeof setTimeout>;
1259
1531
  let session: ILiveBrowserSessionLike;
1260
1532
  let generation: number;
1533
+ let incarnationGeneration: number;
1534
+ let externalAbort = false;
1535
+ let onExternalAbort: (() => void) | undefined;
1536
+ let resolvePreflight!: () => void;
1261
1537
  const startedAt = Date.now();
1262
1538
  try {
1263
1539
  this.requireValidLease(lease);
1264
1540
  this.assertSlotAvailable(slot);
1265
1541
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1266
1542
  session = slot.session!;
1267
- generation = slot.generation;
1543
+ generation = slot.arbitrationGeneration;
1544
+ incarnationGeneration = slot.incarnationGeneration;
1268
1545
  const timeoutMs = operationOptions.timeoutMs === undefined
1269
1546
  ? this.options.operationTimeoutMs
1270
1547
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
@@ -1274,20 +1551,25 @@ export class BrowserRuntime {
1274
1551
  timeoutController.abort(new BrowserRuntimeError('TIMEOUT'));
1275
1552
  }, timeoutMs);
1276
1553
  timeout.unref();
1554
+ const externalSignal = operationOptions.signal;
1555
+ onExternalAbort = () => { externalAbort = true; };
1556
+ externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
1557
+ if (externalSignal?.aborted) externalAbort = true;
1277
1558
  combinedSignal = AbortSignal.any([
1278
1559
  controller.signal,
1279
1560
  lease.controller.signal,
1280
- operationOptions.signal ?? new AbortController().signal,
1561
+ externalSignal ?? new AbortController().signal,
1281
1562
  timeoutController.signal,
1282
1563
  ]);
1283
- executionPromise = Promise.resolve().then(() => execute(combinedSignal, session));
1284
1564
  operation = {
1285
1565
  operationId: randomId(18),
1286
1566
  lease,
1287
1567
  controller,
1288
1568
  action,
1289
1569
  startedAt,
1290
- promise: executionPromise.then(() => undefined, () => undefined),
1570
+ session,
1571
+ incarnationGeneration,
1572
+ promise: new Promise<void>((resolve) => { resolvePreflight = resolve; }),
1291
1573
  };
1292
1574
  slot.operation = operation;
1293
1575
  try {
@@ -1299,67 +1581,173 @@ export class BrowserRuntime {
1299
1581
  release();
1300
1582
  }
1301
1583
 
1302
- let phase: 'completed' | 'failed' = 'completed';
1303
- let errorCode: string | undefined;
1584
+ const operationIdentity = this.createOperationIdentity(lease, operation, action, startedAt);
1585
+
1586
+ let result: T | undefined;
1587
+ let failed = false;
1588
+ let failure: unknown;
1589
+ let executionStarted = false;
1304
1590
  try {
1591
+ await this.runBeforeOperation(operationIdentity, combinedSignal);
1592
+ const executionRelease = await slot.mutex.acquire();
1593
+ try {
1594
+ combinedSignal.throwIfAborted();
1595
+ this.requireValidLease(lease);
1596
+ this.assertSlotAvailable(slot);
1597
+ this.assertCapabilityStillAttached(lease.capability, slot);
1598
+ if (
1599
+ slot.operation !== operation
1600
+ || slot.session !== session
1601
+ || slot.arbitrationGeneration !== generation
1602
+ || slot.incarnationGeneration !== incarnationGeneration
1603
+ ) throw new BrowserRuntimeError('ABORTED');
1604
+ executionStarted = true;
1605
+ executionPromise = execute(combinedSignal, session);
1606
+ operation.promise = executionPromise.then(() => undefined, () => undefined);
1607
+ } finally {
1608
+ executionRelease();
1609
+ }
1305
1610
  let onAbort!: () => void;
1306
1611
  const aborted = new Promise<never>((_resolve, reject) => {
1307
1612
  onAbort = () => reject(combinedSignal.reason ?? new BrowserRuntimeError('ABORTED'));
1308
1613
  combinedSignal.addEventListener('abort', onAbort, { once: true });
1309
1614
  });
1310
- let result: T;
1311
1615
  try {
1312
- result = await Promise.race([executionPromise!, aborted]);
1616
+ result = await Promise.race([executionPromise, aborted]);
1313
1617
  } finally {
1314
1618
  combinedSignal.removeEventListener('abort', onAbort);
1315
1619
  }
1316
1620
  if (combinedSignal.aborted) throw combinedSignal.reason;
1317
1621
  this.requireValidLease(lease);
1318
- if (slot.session !== session! || slot.generation !== generation!) {
1622
+ this.assertCapabilityStillAttached(lease.capability, slot);
1623
+ if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
1319
1624
  throw new BrowserRuntimeError('ABORTED');
1320
1625
  }
1321
- return result;
1322
1626
  } catch (error) {
1323
- phase = 'failed';
1324
- errorCode = error instanceof BrowserRuntimeError
1325
- ? error.code
1326
- : error instanceof Error
1327
- ? truncateString(error.name, 64)
1328
- : 'Error';
1329
- if (combinedSignal!.aborted) {
1330
- this.trackCleanup(this.enforceOperationQuiescence(operation!));
1331
- const reason = combinedSignal!.reason;
1332
- throw reason instanceof BrowserRuntimeError
1333
- ? reason
1334
- : new BrowserRuntimeError(
1335
- reason instanceof Error && reason.name === 'TimeoutError' ? 'TIMEOUT' : 'ABORTED',
1336
- );
1337
- }
1338
- throw error;
1627
+ failed = true;
1628
+ failure = this.normalizeOperationError(error, combinedSignal!, externalAbort);
1339
1629
  } finally {
1340
1630
  clearTimeout(timeout!);
1341
- executionPromise!.finally(async () => {
1631
+ operationOptions.signal?.removeEventListener('abort', onExternalAbort!);
1632
+ resolvePreflight();
1633
+ const cleanupSlot = async (): Promise<void> => {
1342
1634
  const cleanupRelease = await slot.mutex.acquire();
1343
1635
  try {
1344
- if (slot.operation === operation!) slot.operation = undefined;
1636
+ if (slot.operation === operation) slot.operation = undefined;
1345
1637
  } finally {
1346
1638
  cleanupRelease();
1347
1639
  }
1348
- }).catch(() => undefined);
1349
- await this.emitAudit({
1350
- operationId: operation!.operationId,
1351
- capabilityId: lease.capability.capabilityId,
1352
- projectId: lease.capability.projectId,
1353
- actorId: lease.capability.actorId,
1354
- role: lease.role,
1355
- source: lease.capability.source,
1356
- action,
1357
- phase,
1358
- startedAt,
1640
+ };
1641
+ if (executionStarted) {
1642
+ if (combinedSignal!.aborted) {
1643
+ await this.enforceOperationQuiescence(operation).catch(() => undefined);
1644
+ }
1645
+ if (slot.operation === operation) {
1646
+ const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
1647
+ if (settled.settled) await cleanupSlot();
1648
+ }
1649
+ } else {
1650
+ await cleanupSlot();
1651
+ }
1652
+ const errorCode = failed ? this.operationErrorCode(failure) : undefined;
1653
+ await this.emitAudit(Object.freeze({
1654
+ ...operationIdentity,
1655
+ phase: failed ? 'failed' : 'completed',
1359
1656
  finishedAt: Date.now(),
1360
1657
  ...(errorCode ? { errorCode } : {}),
1361
- });
1658
+ }));
1659
+ }
1660
+ if (failed) throw failure;
1661
+ return result as T;
1662
+ }
1663
+
1664
+ private normalizeOperationError(
1665
+ errorArg: unknown,
1666
+ signalArg: AbortSignal,
1667
+ externalAbortArg: boolean,
1668
+ ): unknown {
1669
+ if (signalArg.aborted) {
1670
+ if (externalAbortArg) return new BrowserRuntimeError('ABORTED');
1671
+ const reason = signalArg.reason;
1672
+ if (reason instanceof BrowserRuntimeError) return reason;
1673
+ return new BrowserRuntimeError(
1674
+ reason instanceof Error && reason.name === 'TimeoutError' ? 'TIMEOUT' : 'ABORTED',
1675
+ );
1362
1676
  }
1677
+ if (errorArg instanceof BrowserRuntimeError) return errorArg;
1678
+ if (errorArg instanceof Error && errorArg.name === 'TimeoutError') {
1679
+ return new BrowserRuntimeError('TIMEOUT');
1680
+ }
1681
+ if (errorArg instanceof Error && errorArg.name === 'AbortError') {
1682
+ return new BrowserRuntimeError('ABORTED');
1683
+ }
1684
+ return errorArg;
1685
+ }
1686
+
1687
+ private operationErrorCode(errorArg: unknown): string {
1688
+ return errorArg instanceof BrowserRuntimeError
1689
+ ? errorArg.code
1690
+ : errorArg instanceof Error
1691
+ ? truncateString(errorArg.name, 64)
1692
+ : 'Error';
1693
+ }
1694
+
1695
+ private async runBeforeOperation(
1696
+ operationArg: TBrowserRuntimeOperationIdentity,
1697
+ signalArg: AbortSignal,
1698
+ ): Promise<void> {
1699
+ if (!this.options.beforeOperation) return;
1700
+ signalArg.throwIfAborted();
1701
+ const controller = new AbortController();
1702
+ const signal = AbortSignal.any([signalArg, controller.signal]);
1703
+ const hook = Promise.resolve().then(() => this.options.beforeOperation!(operationArg, signal));
1704
+ void hook.catch(() => undefined);
1705
+ let onAbort!: () => void;
1706
+ const aborted = new Promise<never>((_resolve, reject) => {
1707
+ onAbort = () => reject(signal.reason ?? new BrowserRuntimeError('ABORTED'));
1708
+ signal.addEventListener('abort', onAbort, { once: true });
1709
+ });
1710
+ let result: Awaited<ReturnType<typeof waitBounded<void>>>;
1711
+ try {
1712
+ try {
1713
+ result = await waitBounded(
1714
+ Promise.race([hook, aborted]),
1715
+ this.options.beforeOperationTimeoutMs,
1716
+ );
1717
+ } catch {
1718
+ if (signalArg.aborted) throw signalArg.reason;
1719
+ controller.abort(new BrowserRuntimeError('AUTHORIZATION_DENIED'));
1720
+ throw new BrowserRuntimeError('AUTHORIZATION_DENIED');
1721
+ }
1722
+ } finally {
1723
+ signal.removeEventListener('abort', onAbort);
1724
+ }
1725
+ if (!result.settled) {
1726
+ controller.abort(new BrowserRuntimeError('TIMEOUT'));
1727
+ throw new BrowserRuntimeError('TIMEOUT');
1728
+ }
1729
+ signalArg.throwIfAborted();
1730
+ }
1731
+
1732
+ private createOperationIdentity(
1733
+ leaseArg: ILeaseRecord,
1734
+ operationArg: IOperationRecord,
1735
+ actionArg: string,
1736
+ startedAtArg: number,
1737
+ ): TBrowserRuntimeOperationIdentity {
1738
+ const identity = this.describeCapabilityIdentity(leaseArg.capability);
1739
+ const sessionId = identity.role === 'agent'
1740
+ ? Object.freeze({ ...identity.sessionId })
1741
+ : undefined;
1742
+ return Object.freeze({
1743
+ ...identity,
1744
+ ...(sessionId ? { sessionId } : {}),
1745
+ operationId: operationArg.operationId,
1746
+ capabilityId: leaseArg.capability.capabilityId,
1747
+ leaseId: leaseArg.leaseId,
1748
+ action: actionArg,
1749
+ startedAt: startedAtArg,
1750
+ }) as TBrowserRuntimeOperationIdentity;
1363
1751
  }
1364
1752
 
1365
1753
  private async executeAgentActionInternal(
@@ -1376,7 +1764,7 @@ export class BrowserRuntime {
1376
1764
  }, { signal });
1377
1765
  return {
1378
1766
  action: 'navigate',
1379
- state: this.projectState(session.getState()),
1767
+ state: this.resourceState(session.getState()),
1380
1768
  } satisfies IBrowserActionStateResult;
1381
1769
  }
1382
1770
  if (action.action === 'snapshot') {
@@ -1391,7 +1779,7 @@ export class BrowserRuntime {
1391
1779
  title: truncateString(observation.title, 2048),
1392
1780
  text: truncateString(observation.text, 50_000),
1393
1781
  truncated: observation.truncated,
1394
- state: this.projectState(observation.state),
1782
+ state: this.resourceState(observation.state),
1395
1783
  } satisfies IBrowserObservationResult;
1396
1784
  }
1397
1785
  if (action.action === 'screenshot') {
@@ -1406,6 +1794,7 @@ export class BrowserRuntime {
1406
1794
  if (lease.slot.session !== session) throw new BrowserRuntimeError('ABORTED');
1407
1795
  const artifact = await this.artifactStore.store(
1408
1796
  lease.capability.projectId,
1797
+ lease.capability.browserResourceId,
1409
1798
  snapshot.mimeType,
1410
1799
  snapshot.data,
1411
1800
  );
@@ -1438,7 +1827,7 @@ export class BrowserRuntime {
1438
1827
  }
1439
1828
  return {
1440
1829
  action: action.action,
1441
- state: this.projectState(session.getState()),
1830
+ state: this.resourceState(session.getState()),
1442
1831
  } satisfies IBrowserActionStateResult;
1443
1832
  }
1444
1833
 
@@ -1456,7 +1845,7 @@ export class BrowserRuntime {
1456
1845
  };
1457
1846
  }
1458
1847
 
1459
- private projectState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
1848
+ private resourceState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
1460
1849
  return {
1461
1850
  status: state.status,
1462
1851
  activeTabId: state.activeTabId ? truncateString(state.activeTabId, 128) : null,
@@ -1491,7 +1880,7 @@ export class BrowserRuntime {
1491
1880
  }
1492
1881
 
1493
1882
  private handleSessionEvent(
1494
- slot: IProjectSlot,
1883
+ slot: IResourceSlot,
1495
1884
  session: ILiveBrowserSessionLike,
1496
1885
  event: plugins.smartpuppeteer.TLiveBrowserEvent,
1497
1886
  ): void {
@@ -1530,19 +1919,27 @@ export class BrowserRuntime {
1530
1919
  return;
1531
1920
  }
1532
1921
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
1533
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1922
+ this.trackCleanup(this.acknowledgeFrameInBackground(
1923
+ slot,
1924
+ session,
1925
+ acknowledgement,
1926
+ true,
1927
+ ));
1534
1928
  this.pushToSubscription(subscription, {
1535
1929
  type: 'error',
1536
1930
  error: { code: 'FRAME_TOO_LARGE', fatal: true, tabId: event.frame.tabId },
1537
1931
  });
1538
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1539
1932
  return;
1540
1933
  }
1541
1934
  const timer = setTimeout(() => {
1542
1935
  if (subscription.outstanding?.acknowledgement.sequence !== acknowledgement.sequence) return;
1543
1936
  subscription.outstanding = undefined;
1544
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1545
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1937
+ this.trackCleanup(this.acknowledgeFrameInBackground(
1938
+ slot,
1939
+ session,
1940
+ acknowledgement,
1941
+ true,
1942
+ ));
1546
1943
  }, this.options.frameAcknowledgementTimeoutMs);
1547
1944
  timer.unref();
1548
1945
  subscription.outstanding = { acknowledgement, timer };
@@ -1554,7 +1951,7 @@ export class BrowserRuntime {
1554
1951
  }
1555
1952
  if (!subscription || subscription.closed) return;
1556
1953
  if (event.type === 'state') {
1557
- this.pushToSubscription(subscription, { type: 'state', state: this.projectState(event.state) });
1954
+ this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
1558
1955
  } else {
1559
1956
  this.pushToSubscription(subscription, {
1560
1957
  type: 'error',
@@ -1575,17 +1972,34 @@ export class BrowserRuntime {
1575
1972
  this.requireValidLease(subscription.lease);
1576
1973
  subscription.listener(event);
1577
1974
  } catch {
1975
+ const slot = subscription.lease.slot;
1976
+ const session = slot.session;
1977
+ const incarnationGeneration = slot.incarnationGeneration;
1578
1978
  if (event.type === 'frame' && subscription.outstanding) {
1579
1979
  const outstanding = subscription.outstanding;
1580
1980
  clearTimeout(outstanding.timer);
1581
1981
  subscription.outstanding = undefined;
1582
1982
  this.trackCleanup(this.acknowledgeFrameInBackground(
1583
- subscription.lease.slot,
1584
- subscription.lease.slot.session!,
1983
+ slot,
1984
+ session!,
1585
1985
  outstanding.acknowledgement,
1986
+ true,
1586
1987
  ));
1988
+ return;
1989
+ }
1990
+ if (!session) {
1991
+ this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1992
+ return;
1587
1993
  }
1588
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1994
+ this.trackCleanup((async () => {
1995
+ await this.options.beforeFrameFailureTermination?.();
1996
+ await this.handleFrameFailure(
1997
+ slot,
1998
+ session,
1999
+ incarnationGeneration,
2000
+ subscription.lease,
2001
+ );
2002
+ })());
1589
2003
  }
1590
2004
  }
1591
2005
 
@@ -1600,42 +2014,130 @@ export class BrowserRuntime {
1600
2014
  subscription.outstanding = undefined;
1601
2015
  const session = slot.session;
1602
2016
  if (session) {
2017
+ const incarnationGeneration = slot.incarnationGeneration;
1603
2018
  const operation = session.acknowledgeFrame(acknowledgement);
1604
2019
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
1605
2020
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
1606
2021
  if ((!result.settled || !result.value.accepted) && slot.session === session) {
1607
2022
  operation.catch(() => undefined);
1608
- await this.terminateSlotSession(slot);
2023
+ await this.options.beforeFrameFailureTermination?.();
2024
+ await this.handleFrameFailure(
2025
+ slot,
2026
+ session,
2027
+ incarnationGeneration,
2028
+ subscription.lease,
2029
+ );
1609
2030
  }
1610
2031
  }
1611
2032
  }
1612
2033
  }
1613
2034
 
1614
2035
  private async acknowledgeFrameInBackground(
1615
- slot: IProjectSlot,
2036
+ slot: IResourceSlot,
1616
2037
  session: ILiveBrowserSessionLike,
1617
2038
  acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
2039
+ failLease = false,
1618
2040
  ): Promise<void> {
2041
+ const failedLease = slot.session === session ? slot.lease : undefined;
2042
+ const incarnationGeneration = slot.incarnationGeneration;
1619
2043
  const operation = session.acknowledgeFrame(acknowledgement);
1620
2044
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
1621
2045
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
1622
- if (result.settled && result.value.accepted) return;
2046
+ if (result.settled && result.value.accepted && !failLease) return;
1623
2047
  operation.catch(() => undefined);
1624
- if (slot.session !== session) return;
1625
- if (slot.lease) await this.revokeCapabilityRecord(slot.lease.capability);
1626
- else await this.retireSlot(slot.projectId, true);
2048
+ await this.options.beforeFrameFailureTermination?.();
2049
+ await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
2050
+ }
2051
+
2052
+ private async handleFrameFailure(
2053
+ slot: IResourceSlot,
2054
+ session: ILiveBrowserSessionLike,
2055
+ incarnationGeneration: number,
2056
+ lease?: ILeaseRecord,
2057
+ ): Promise<void> {
2058
+ let transitionGeneration = 0;
2059
+ let ownsTransitionFence = false;
2060
+ const release = await slot.mutex.acquire();
2061
+ try {
2062
+ if (lease) this.invalidateCapabilityRecord(lease.capability);
2063
+ const borrowsLeaseReleaseFence = Boolean(
2064
+ lease?.releaseGeneration !== undefined
2065
+ && slot.fencingGeneration === lease.releaseGeneration,
2066
+ );
2067
+ if (
2068
+ slot.session !== session
2069
+ || slot.incarnationGeneration !== incarnationGeneration
2070
+ || (lease && slot.lease !== lease)
2071
+ || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
2072
+ || slot.attachmentRetryBinding
2073
+ ) {
2074
+ if (lease) this.trackFrameCapabilityRevocation(lease);
2075
+ return;
2076
+ }
2077
+ if (borrowsLeaseReleaseFence) {
2078
+ transitionGeneration = slot.fencingGeneration!;
2079
+ } else {
2080
+ transitionGeneration = ++slot.arbitrationGeneration;
2081
+ slot.fencingGeneration = transitionGeneration;
2082
+ ownsTransitionFence = true;
2083
+ }
2084
+ if (lease) {
2085
+ lease.released = true;
2086
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2087
+ }
2088
+ if (!lease || slot.operation?.lease === lease) {
2089
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2090
+ }
2091
+ } finally {
2092
+ release();
2093
+ }
2094
+ if (lease) this.trackFrameCapabilityRevocation(lease);
2095
+ await this.options.afterFrameFailureFence?.();
2096
+ if (lease) await this.closeFrameSubscriptionIfLease(lease);
2097
+ const errors: unknown[] = [];
2098
+ await this.quiesceSlot(slot).catch((error) => errors.push(error));
2099
+ await this.terminateSlotSession(slot, session).catch((error) => errors.push(error));
2100
+ const finalRelease = await slot.mutex.acquire();
2101
+ try {
2102
+ if (
2103
+ slot.arbitrationGeneration === transitionGeneration
2104
+ && slot.fencingGeneration === transitionGeneration
2105
+ && slot.incarnationGeneration === incarnationGeneration
2106
+ && slot.session !== session
2107
+ ) {
2108
+ if (ownsTransitionFence && lease && slot.lease === lease) slot.lease = undefined;
2109
+ if (ownsTransitionFence && lease && lease.capability.lease === lease) {
2110
+ lease.capability.lease = undefined;
2111
+ }
2112
+ if (!lease || slot.operation?.lease === lease) slot.operation = undefined;
2113
+ if (ownsTransitionFence && errors.length === 0) slot.fencingGeneration = undefined;
2114
+ }
2115
+ } finally {
2116
+ finalRelease();
2117
+ }
2118
+ if (errors.length === 1) throw errors[0];
2119
+ if (errors.length > 1) {
2120
+ throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
2121
+ }
2122
+ }
2123
+
2124
+ private trackFrameCapabilityRevocation(lease: ILeaseRecord): void {
2125
+ const operation = lease.releasePromise
2126
+ ? lease.releasePromise.catch(() => undefined).then(() => (
2127
+ this.revokeCapabilityRecord(lease.capability)
2128
+ ))
2129
+ : this.revokeCapabilityRecord(lease.capability);
2130
+ this.trackCleanup(operation);
1627
2131
  }
1628
2132
 
1629
2133
  private requireValidLease(lease: ILeaseRecord): void {
1630
2134
  if (lease.released || lease.controller.signal.aborted) {
1631
2135
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1632
2136
  }
1633
- const capability = this.resolveCapability(
1634
- lease.digest,
1635
- lease.peerId,
1636
- lease.role,
1637
- lease.capability.source,
1638
- );
2137
+ const capability = this.resolveCapability(lease.digest, this.describeCapabilityIdentity(
2138
+ lease.capability,
2139
+ ));
2140
+ this.assertCapabilityStillAttached(capability, lease.slot);
1639
2141
  if (
1640
2142
  capability !== lease.capability
1641
2143
  || capability.lease !== lease
@@ -1648,14 +2150,10 @@ export class BrowserRuntime {
1648
2150
 
1649
2151
  private resolveCapability(
1650
2152
  digest: plugins.Buffer,
1651
- peerId: string,
1652
- expectedRole?: TBrowserActorRole,
1653
- expectedSource?: TBrowserCapabilitySource,
1654
- expectedScopeId?: string,
1655
- expectedSessionId?: string,
1656
- ): ICapabilityRecord {
2153
+ expected: TBrowserCapabilityAuthorizationRequest,
2154
+ ): TCapabilityRecord {
1657
2155
  const record = this.capabilitiesByDigest.get(digest.toString('hex'));
1658
- if (!record || !digestsEqual(record.digest, digest) || record.peerId !== peerId) {
2156
+ if (!record || !digestsEqual(record.digest, digest)) {
1659
2157
  throw new BrowserRuntimeError('CAPABILITY_INVALID');
1660
2158
  }
1661
2159
  if (record.expiresAt <= Date.now()) {
@@ -1664,41 +2162,28 @@ export class BrowserRuntime {
1664
2162
  throw new BrowserRuntimeError('CAPABILITY_EXPIRED');
1665
2163
  }
1666
2164
  if (record.state !== 'active') throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1667
- if (
1668
- (expectedRole && record.role !== expectedRole)
1669
- || (expectedSource && record.source !== expectedSource)
1670
- || (expectedScopeId && record.scopeId !== expectedScopeId)
1671
- || (expectedSessionId && record.sessionId !== expectedSessionId)
1672
- ) {
2165
+ if (!this.capabilityIdentitiesEqual(record, expected)) {
1673
2166
  throw new BrowserRuntimeError('CAPABILITY_INVALID');
1674
2167
  }
1675
2168
  return record;
1676
2169
  }
1677
2170
 
1678
- private describeCapability(record: ICapabilityRecord): IBrowserCapabilityDescriptor {
2171
+ private describeCapability(record: TCapabilityRecord): TBrowserCapabilityDescriptor {
1679
2172
  return {
2173
+ ...this.describeCapabilityIdentity(record),
1680
2174
  capabilityId: record.capabilityId,
1681
- projectId: record.projectId,
1682
- actorId: record.actorId,
1683
- role: record.role,
1684
- peerId: record.peerId,
1685
- source: record.source,
1686
- ...(record.scopeId ? { scopeId: record.scopeId } : {}),
1687
- ...(record.sessionId ? { sessionId: record.sessionId } : {}),
1688
2175
  expiresAt: record.expiresAt,
1689
2176
  };
1690
2177
  }
1691
2178
 
1692
2179
  private async revokeCapabilityRecord(
1693
- capability: ICapabilityRecord,
2180
+ capability: TCapabilityRecord,
1694
2181
  invokeDisconnect = true,
1695
2182
  ): Promise<void> {
1696
2183
  if (capability.revocationComplete) return;
1697
2184
  if (capability.revocationPromise) return capability.revocationPromise;
1698
2185
  capability.revocationInvokeDisconnect ??= invokeDisconnect;
1699
- if (capability.state === 'active') capability.state = 'revoked';
1700
- clearTimeout(capability.expiryTimer);
1701
- this.capabilitiesByDigest.delete(capability.digest.toString('hex'));
2186
+ this.invalidateCapabilityRecord(capability);
1702
2187
  const operation = (async () => {
1703
2188
  const errors: unknown[] = [];
1704
2189
  const lease = capability.lease;
@@ -1732,9 +2217,11 @@ export class BrowserRuntime {
1732
2217
  capability.disconnectComplete = true;
1733
2218
  }
1734
2219
  } else {
1735
- const framedPeer = [...this.framedPeers].find((peer) => peer.peerId === capability.peerId);
2220
+ const framedPeer = [...this.framedPeers].find((peer) => (
2221
+ peer.activeCapabilityId === capability.capabilityId
2222
+ ));
1736
2223
  if (framedPeer) {
1737
- await framedPeer.close().then(
2224
+ await framedPeer.closeFromRuntime().then(
1738
2225
  () => { capability.disconnectComplete = true; },
1739
2226
  (error) => errors.push(error),
1740
2227
  );
@@ -1746,17 +2233,6 @@ export class BrowserRuntime {
1746
2233
  if (errors.length > 0) throw new AggregateError(errors, 'Capability cleanup is incomplete');
1747
2234
  capability.revocationComplete = true;
1748
2235
  this.capabilitiesById.delete(capability.capabilityId);
1749
- const slot = this.slots.get(capability.projectId);
1750
- if (
1751
- slot
1752
- && !slot.mutex.busy
1753
- && !slot.session
1754
- && !slot.lease
1755
- && !slot.operation
1756
- && ![...this.capabilitiesById.values()].some((record) => (
1757
- record.projectId === capability.projectId
1758
- ))
1759
- ) this.slots.delete(capability.projectId);
1760
2236
  })();
1761
2237
  capability.revocationPromise = operation;
1762
2238
  try {
@@ -1770,6 +2246,12 @@ export class BrowserRuntime {
1770
2246
  }
1771
2247
  }
1772
2248
 
2249
+ private invalidateCapabilityRecord(capability: TCapabilityRecord): void {
2250
+ if (capability.state === 'active') capability.state = 'revoked';
2251
+ clearTimeout(capability.expiryTimer);
2252
+ this.capabilitiesByDigest.delete(capability.digest.toString('hex'));
2253
+ }
2254
+
1773
2255
  private async releaseLeaseRecord(lease: ILeaseRecord): Promise<void> {
1774
2256
  if (lease.releasePromise) return lease.releasePromise;
1775
2257
  const operation = this.releaseLeaseRecordInternal(lease);
@@ -1785,10 +2267,14 @@ export class BrowserRuntime {
1785
2267
  if (lease.released && lease.slot.lease !== lease) return;
1786
2268
  const slot = lease.slot;
1787
2269
  const release = await slot.mutex.acquire();
1788
- const generation = lease.releaseGeneration ?? ++slot.generation;
2270
+ const existingFence = slot.fencingGeneration;
2271
+ const generation = lease.releaseGeneration
2272
+ ?? existingFence
2273
+ ?? ++slot.arbitrationGeneration;
1789
2274
  lease.releaseGeneration = generation;
2275
+ lease.releaseOwnsFence ??= existingFence === undefined;
1790
2276
  try {
1791
- if (slot.fencingGeneration !== undefined && slot.fencingGeneration !== generation) {
2277
+ if (existingFence !== undefined) {
1792
2278
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
1793
2279
  } else {
1794
2280
  slot.fencingGeneration = generation;
@@ -1809,11 +2295,15 @@ export class BrowserRuntime {
1809
2295
  ) await this.terminateSlotSession(slot);
1810
2296
  const finalRelease = await slot.mutex.acquire();
1811
2297
  try {
2298
+ if (lease.capability.state === 'active') this.notifyFramedLeaseEnded(lease);
1812
2299
  if (slot.lease === lease) slot.lease = undefined;
1813
2300
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
1814
- if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
2301
+ if (lease.releaseOwnsFence && slot.fencingGeneration === generation) {
2302
+ slot.fencingGeneration = undefined;
2303
+ }
1815
2304
  lease.releaseGeneration = undefined;
1816
- this.scheduleIdleRetirement(slot);
2305
+ lease.releaseOwnsFence = undefined;
2306
+ this.scheduleIdleTermination(slot);
1817
2307
  } finally {
1818
2308
  finalRelease();
1819
2309
  }
@@ -1824,7 +2314,7 @@ export class BrowserRuntime {
1824
2314
  if (subscription?.lease === lease) await this.closeFrameSubscription(subscription);
1825
2315
  }
1826
2316
 
1827
- private async quiesceSlot(slot: IProjectSlot): Promise<void> {
2317
+ private async quiesceSlot(slot: IResourceSlot): Promise<void> {
1828
2318
  const operation = slot.operation;
1829
2319
  if (!operation) return;
1830
2320
  operation.controller.abort(new BrowserRuntimeError('ABORTED'));
@@ -1835,13 +2325,54 @@ export class BrowserRuntime {
1835
2325
  }
1836
2326
 
1837
2327
  private async enforceOperationQuiescence(operation: IOperationRecord): Promise<void> {
1838
- const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
1839
- if (settled.settled || operation.lease.released) return;
1840
- await this.revokeCapabilityRecord(operation.lease.capability);
2328
+ let settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
2329
+ if (settled.settled) return;
2330
+ let cleanupError: unknown;
2331
+ try {
2332
+ if (!operation.lease.released) {
2333
+ await this.revokeCapabilityRecord(operation.lease.capability);
2334
+ } else if (operation.lease.releasePromise) {
2335
+ await waitBounded(operation.lease.releasePromise, this.options.quiescenceTimeoutMs);
2336
+ }
2337
+ } catch (error) {
2338
+ cleanupError = error;
2339
+ }
2340
+ if (operation.lease.slot.operation !== operation) {
2341
+ if (cleanupError) throw cleanupError;
2342
+ return;
2343
+ }
2344
+ settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
2345
+ if (settled.settled) {
2346
+ if (cleanupError) throw cleanupError;
2347
+ return;
2348
+ }
2349
+ const slot = operation.lease.slot;
2350
+ const release = await slot.mutex.acquire();
2351
+ try {
2352
+ if (
2353
+ slot.operation === operation
2354
+ && (
2355
+ slot.permanentlyFenced
2356
+ || slot.session !== operation.session
2357
+ || slot.incarnationGeneration !== operation.incarnationGeneration
2358
+ )
2359
+ ) slot.operation = undefined;
2360
+ } finally {
2361
+ release();
2362
+ }
2363
+ if (cleanupError) throw cleanupError;
1841
2364
  }
1842
2365
 
1843
- private async terminateSlotSession(slot: IProjectSlot): Promise<void> {
2366
+ private async terminateSlotSession(
2367
+ slot: IResourceSlot,
2368
+ expectedSession?: ILiveBrowserSessionLike,
2369
+ ): Promise<void> {
2370
+ if (slot.idleTimer) {
2371
+ clearTimeout(slot.idleTimer);
2372
+ slot.idleTimer = undefined;
2373
+ }
1844
2374
  const session = slot.session;
2375
+ if (expectedSession && session !== expectedSession) return;
1845
2376
  if (session) {
1846
2377
  let termination: plugins.smartpuppeteer.ILiveBrowserTerminationResult;
1847
2378
  try {
@@ -1881,13 +2412,24 @@ export class BrowserRuntime {
1881
2412
  if (slot.proxy || slot.profileDirectory) throw new BrowserRuntimeError('FENCED');
1882
2413
  }
1883
2414
 
1884
- private scheduleIdleRetirement(slot: IProjectSlot): void {
2415
+ private scheduleIdleTermination(slot: IResourceSlot): void {
2416
+ if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
1885
2417
  if (slot.idleTimer) clearTimeout(slot.idleTimer);
1886
- const generation = slot.generation;
2418
+ const arbitrationGeneration = slot.arbitrationGeneration;
2419
+ const incarnationGeneration = slot.incarnationGeneration;
2420
+ const scheduledSession = slot.session;
1887
2421
  slot.idleTimer = setTimeout(() => {
1888
- if (slot.generation !== generation || slot.lease) return;
1889
- this.trackCleanup(this.retireSlot(slot.projectId, false));
1890
- }, this.options.idleRetirementMs);
2422
+ slot.idleTimer = undefined;
2423
+ this.trackCleanup(this.enqueueResourceLifecycle(slot, async () => {
2424
+ await this.options.beforeIdleTermination?.();
2425
+ await this.terminateRegisteredResourceInternal(slot, false, {
2426
+ arbitrationGeneration,
2427
+ incarnationGeneration,
2428
+ session: scheduledSession,
2429
+ requireIdle: true,
2430
+ });
2431
+ }));
2432
+ }, this.options.idleTerminationMs);
1891
2433
  slot.idleTimer.unref();
1892
2434
  }
1893
2435
 
@@ -1903,14 +2445,33 @@ export class BrowserRuntime {
1903
2445
  }
1904
2446
  }
1905
2447
 
1906
- private async retireSlot(projectId: string, force: boolean): Promise<void> {
1907
- const slot = this.slots.get(projectId);
1908
- if (!slot) return;
2448
+ private async terminateRegisteredResourceInternal(
2449
+ slot: IResourceSlot,
2450
+ stopping: boolean,
2451
+ expected?: {
2452
+ arbitrationGeneration?: number;
2453
+ incarnationGeneration?: number;
2454
+ session?: ILiveBrowserSessionLike;
2455
+ requireIdle?: boolean;
2456
+ },
2457
+ ): Promise<void> {
1909
2458
  const release = await slot.mutex.acquire();
1910
- const generation = ++slot.generation;
2459
+ let generation = 0;
2460
+ let attachmentFenceOwnsTransition = false;
1911
2461
  try {
1912
- if (!force && (slot.lease || slot.operation)) return;
1913
- slot.fencingGeneration = generation;
2462
+ if (
2463
+ (expected?.arbitrationGeneration !== undefined
2464
+ && slot.arbitrationGeneration !== expected.arbitrationGeneration)
2465
+ || (expected?.incarnationGeneration !== undefined
2466
+ && slot.incarnationGeneration !== expected.incarnationGeneration)
2467
+ || (expected?.session !== undefined && slot.session !== expected.session)
2468
+ || (expected?.requireIdle && slot.lease)
2469
+ ) return;
2470
+ attachmentFenceOwnsTransition = slot.attachmentRetryBinding !== undefined;
2471
+ if (!attachmentFenceOwnsTransition) {
2472
+ generation = ++slot.arbitrationGeneration;
2473
+ slot.fencingGeneration = generation;
2474
+ }
1914
2475
  if (slot.idleTimer) {
1915
2476
  clearTimeout(slot.idleTimer);
1916
2477
  slot.idleTimer = undefined;
@@ -1918,18 +2479,27 @@ export class BrowserRuntime {
1918
2479
  if (slot.lease) {
1919
2480
  slot.lease.released = true;
1920
2481
  slot.lease.controller.abort(new BrowserRuntimeError('ABORTED'));
2482
+ if (!stopping && !attachmentFenceOwnsTransition) {
2483
+ this.notifyFramedLeaseEnded(slot.lease);
2484
+ }
1921
2485
  }
1922
2486
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1923
2487
  } finally {
1924
2488
  release();
1925
2489
  }
2490
+ if (attachmentFenceOwnsTransition) {
2491
+ await this.quiesceSlot(slot);
2492
+ if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
2493
+ await this.terminateSlotSession(slot, expected?.session);
2494
+ return;
2495
+ }
2496
+ if (generation === 0) return;
1926
2497
  await this.quiesceSlot(slot);
1927
2498
  if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
1928
- await this.terminateSlotSession(slot);
1929
- await this.artifactStore.purgeProject(projectId);
2499
+ await this.terminateSlotSession(slot, expected?.session);
1930
2500
  const finalRelease = await slot.mutex.acquire();
1931
2501
  try {
1932
- if (slot.generation !== generation || slot.fencingGeneration !== generation) {
2502
+ if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1933
2503
  throw new BrowserRuntimeError('BUSY');
1934
2504
  }
1935
2505
  const lease = slot.lease;
@@ -1939,14 +2509,48 @@ export class BrowserRuntime {
1939
2509
  slot.lease = undefined;
1940
2510
  slot.operation = undefined;
1941
2511
  slot.fencingGeneration = undefined;
1942
- this.slots.delete(projectId);
1943
2512
  } finally {
1944
2513
  finalRelease();
1945
2514
  }
1946
2515
  }
1947
2516
 
1948
- private assertSlotAvailable(slot: IProjectSlot): void {
2517
+ private async retireRegisteredResourceInternal(slot: IResourceSlot): Promise<void> {
2518
+ const key = this.resourceKey(slot.projectId, slot.browserResourceId);
2519
+ if (this.slots.get(key) !== slot) return;
2520
+ const peerResults = await Promise.allSettled(
2521
+ this.framedPeersForResource(slot).map((peer) => peer.close()),
2522
+ );
2523
+ const capabilities = this.capabilitiesForResource(slot);
2524
+ const capabilityResults = await Promise.allSettled(
2525
+ capabilities.map((record) => this.revokeCapabilityRecord(record)),
2526
+ );
2527
+ const errors = [...peerResults, ...capabilityResults]
2528
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2529
+ .map((result) => result.reason);
2530
+ await this.terminateRegisteredResourceInternal(slot, true).catch((error) => errors.push(error));
2531
+ if (errors.length === 0) {
2532
+ await this.artifactStore.purgeResource(slot.projectId, slot.browserResourceId)
2533
+ .catch((error) => errors.push(error));
2534
+ }
2535
+ if (errors.length > 0) {
2536
+ slot.permanentlyFenced = true;
2537
+ throw new AggregateError(errors, 'Resource retirement is incomplete');
2538
+ }
2539
+ this.slots.delete(key);
2540
+ }
2541
+
2542
+ private framedPeersForResource(slot: IResourceSlot): BrowserRuntimeFramedServerPeer[] {
2543
+ return [...this.framedPeers].filter((peer) => (
2544
+ peer.projectId === slot.projectId
2545
+ && peer.browserResourceId === slot.browserResourceId
2546
+ ));
2547
+ }
2548
+
2549
+ private assertSlotAvailable(slot: IResourceSlot): void {
2550
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
1949
2551
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
2552
+ if (slot.terminationFenceGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
2553
+ if (slot.attachmentFenceCount > 0) throw new BrowserRuntimeError('BUSY');
1950
2554
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1951
2555
  }
1952
2556
 
@@ -1958,6 +2562,416 @@ export class BrowserRuntime {
1958
2562
  if (lease.role !== 'human') throw new BrowserRuntimeError('CAPABILITY_INVALID');
1959
2563
  }
1960
2564
 
2565
+ private enqueueResourceLifecycle<T>(
2566
+ slot: IResourceSlot,
2567
+ operation: () => Promise<T>,
2568
+ ): Promise<T> {
2569
+ slot.lifecycleOperationCount += 1;
2570
+ const queued = slot.lifecycleTail.then(operation, operation);
2571
+ const result = queued.finally(() => {
2572
+ slot.lifecycleOperationCount = Math.max(0, slot.lifecycleOperationCount - 1);
2573
+ });
2574
+ slot.lifecycleTail = result.then(() => undefined, () => undefined);
2575
+ return result;
2576
+ }
2577
+
2578
+ private notifyFramedLeaseEnded(lease: ILeaseRecord): void {
2579
+ if (lease.capability.source !== 'flex') return;
2580
+ const peer = [...this.framedPeers].find((candidate) => (
2581
+ candidate.activeCapabilityId === lease.capability.capabilityId
2582
+ && candidate.channelId === lease.capability.channelId
2583
+ ));
2584
+ peer?.releaseLeaseFromRuntime(lease.capability.capabilityId, lease.leaseId);
2585
+ }
2586
+
2587
+ private async applyAttachmentBindingInternal(
2588
+ slot: IResourceSlot,
2589
+ binding: IBrowserAttachmentBinding,
2590
+ ): Promise<IBrowserResourceRegistration> {
2591
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
2592
+ const current = slot.attachmentBinding;
2593
+ if (binding.attachmentRevision < current.attachmentRevision) {
2594
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2595
+ }
2596
+ if (
2597
+ binding.attachmentRevision === current.attachmentRevision
2598
+ && !slot.attachmentRetryBinding
2599
+ ) {
2600
+ if (!this.bindingsEqual(binding, current)) {
2601
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2602
+ }
2603
+ return this.describeResource(slot);
2604
+ }
2605
+ if (
2606
+ slot.attachmentRetryBinding
2607
+ && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
2608
+ ) throw new BrowserRuntimeError('BUSY');
2609
+ const release = await slot.mutex.acquire();
2610
+ let fenceGeneration = 0;
2611
+ try {
2612
+ fenceGeneration = ++slot.arbitrationGeneration;
2613
+ slot.fencingGeneration = fenceGeneration;
2614
+ slot.attachmentBinding = binding;
2615
+ slot.attachmentRetryBinding = binding;
2616
+ slot.lease?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2617
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2618
+ } finally {
2619
+ release();
2620
+ }
2621
+ const peerResults = await Promise.allSettled(
2622
+ this.framedPeersForResource(slot).map((peer) => peer.close()),
2623
+ );
2624
+ const capabilities = this.capabilitiesForResource(slot);
2625
+ const capabilityResults = await Promise.allSettled(
2626
+ capabilities.map((record) => this.revokeCapabilityRecord(record)),
2627
+ );
2628
+ const errors = [...peerResults, ...capabilityResults]
2629
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2630
+ .map((result) => result.reason);
2631
+ await this.quiesceSlot(slot).catch((error) => errors.push(error));
2632
+ const finalRelease = await slot.mutex.acquire();
2633
+ try {
2634
+ if (slot.fencingGeneration === fenceGeneration && errors.length === 0) {
2635
+ slot.fencingGeneration = undefined;
2636
+ slot.attachmentRetryBinding = undefined;
2637
+ }
2638
+ } finally {
2639
+ finalRelease();
2640
+ }
2641
+ if (errors.length > 0) {
2642
+ throw new AggregateError(errors, 'Attachment fencing is incomplete');
2643
+ }
2644
+ return this.describeResource(slot);
2645
+ }
2646
+
2647
+ private attachmentBindingKey(binding: IBrowserAttachmentBinding): string {
2648
+ const session = binding.sessionId
2649
+ ? `${binding.sessionId.harnessId}:${binding.sessionId.nativeId}`
2650
+ : 'detached';
2651
+ return `${binding.attachmentRevision}:${binding.attachmentAuthorityId}:${session}`;
2652
+ }
2653
+
2654
+ private tombstoneIndex(value: string, seed: number): number {
2655
+ const digest = plugins.crypto
2656
+ .createHash('sha256')
2657
+ .update(String(seed), 'utf8')
2658
+ .update('\0', 'utf8')
2659
+ .update(value, 'utf8')
2660
+ .digest();
2661
+ return digest.readUInt32BE(0) % (this.retiredResourceIds.length * 8);
2662
+ }
2663
+
2664
+ private addRetiredTombstone(tombstones: Uint8Array, value: string): void {
2665
+ for (let seed = 0; seed < 4; seed += 1) {
2666
+ const index = this.tombstoneIndex(value, seed);
2667
+ tombstones[index >>> 3] |= 1 << (index & 7);
2668
+ }
2669
+ }
2670
+
2671
+ private hasRetiredTombstone(tombstones: Uint8Array, value: string): boolean {
2672
+ for (let seed = 0; seed < 4; seed += 1) {
2673
+ const index = this.tombstoneIndex(value, seed);
2674
+ if ((tombstones[index >>> 3]! & (1 << (index & 7))) === 0) return false;
2675
+ }
2676
+ return true;
2677
+ }
2678
+
2679
+ private resourceKey(projectId: string, browserResourceId: string): string {
2680
+ return `${projectId.length}:${projectId}${browserResourceId}`;
2681
+ }
2682
+
2683
+ private validateIdentifier(value: unknown, name: string): string {
2684
+ return validateBoundedString(value, name, 1, identifierMaximum);
2685
+ }
2686
+
2687
+ private validateResourceId(value: unknown): string {
2688
+ return validateBoundedString(value, 'browserResourceId', 16, identifierMaximum);
2689
+ }
2690
+
2691
+ private validateQualifiedSessionId(value: unknown): TQualifiedBrowserSessionId {
2692
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2693
+ throw new BrowserRuntimeError('INVALID_INPUT');
2694
+ }
2695
+ const record = value as Record<string, unknown>;
2696
+ if (Object.keys(record).some((key) => key !== 'harnessId' && key !== 'nativeId')) {
2697
+ throw new BrowserRuntimeError('INVALID_INPUT');
2698
+ }
2699
+ if (record.harnessId !== 'opencode' && record.harnessId !== 'flex') {
2700
+ throw new BrowserRuntimeError('INVALID_INPUT');
2701
+ }
2702
+ return {
2703
+ harnessId: record.harnessId,
2704
+ nativeId: this.validateIdentifier(record.nativeId, 'sessionId.nativeId'),
2705
+ };
2706
+ }
2707
+
2708
+ private validateAttachmentBinding(value: unknown): IBrowserAttachmentBinding {
2709
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2710
+ throw new BrowserRuntimeError('INVALID_INPUT');
2711
+ }
2712
+ const record = value as Record<string, unknown>;
2713
+ if (Object.keys(record).some((key) => (
2714
+ key !== 'attachmentAuthorityId'
2715
+ && key !== 'attachmentRevision'
2716
+ && key !== 'sessionId'
2717
+ ))) throw new BrowserRuntimeError('INVALID_INPUT');
2718
+ const attachmentRevision = validateInteger(
2719
+ record.attachmentRevision,
2720
+ 'attachmentRevision',
2721
+ 0,
2722
+ Number.MAX_SAFE_INTEGER,
2723
+ );
2724
+ const sessionId = record.sessionId === null
2725
+ ? null
2726
+ : this.validateQualifiedSessionId(record.sessionId);
2727
+ if (attachmentRevision === 0 && sessionId !== null) {
2728
+ throw new BrowserRuntimeError('INVALID_INPUT');
2729
+ }
2730
+ return {
2731
+ attachmentAuthorityId: this.validateIdentifier(
2732
+ record.attachmentAuthorityId,
2733
+ 'attachmentAuthorityId',
2734
+ ),
2735
+ attachmentRevision,
2736
+ sessionId,
2737
+ };
2738
+ }
2739
+
2740
+ private bindingsEqual(
2741
+ left: IBrowserAttachmentBinding,
2742
+ right: IBrowserAttachmentBinding,
2743
+ ): boolean {
2744
+ return left.attachmentAuthorityId === right.attachmentAuthorityId
2745
+ && left.attachmentRevision === right.attachmentRevision
2746
+ && this.sessionsEqual(left.sessionId, right.sessionId);
2747
+ }
2748
+
2749
+ private sessionsEqual(
2750
+ left: TQualifiedBrowserSessionId | null | undefined,
2751
+ right: TQualifiedBrowserSessionId | null | undefined,
2752
+ ): boolean {
2753
+ if (left == null || right == null) return left == null && right == null;
2754
+ return left.harnessId === right.harnessId && left.nativeId === right.nativeId;
2755
+ }
2756
+
2757
+ private resolveResourceRequest(
2758
+ request: { projectId: string; browserResourceId: string },
2759
+ ): IResourceSlot {
2760
+ if (!request || typeof request !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
2761
+ return this.getRegisteredSlot(request.projectId, request.browserResourceId);
2762
+ }
2763
+
2764
+ private getRegisteredSlot(projectIdArg: unknown, browserResourceIdArg: unknown): IResourceSlot {
2765
+ const projectId = this.validateIdentifier(projectIdArg, 'projectId');
2766
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
2767
+ const key = this.resourceKey(projectId, browserResourceId);
2768
+ const slot = this.slots.get(key);
2769
+ if (!slot) {
2770
+ throw new BrowserRuntimeError(
2771
+ this.hasRetiredTombstone(this.retiredResourceKeys, key)
2772
+ ? 'RESOURCE_RETIRED'
2773
+ : 'RESOURCE_NOT_FOUND',
2774
+ );
2775
+ }
2776
+ return slot;
2777
+ }
2778
+
2779
+ private describeResource(slot: IResourceSlot): IBrowserResourceRegistration {
2780
+ return {
2781
+ projectId: slot.projectId,
2782
+ browserResourceId: slot.browserResourceId,
2783
+ registeredAt: slot.registeredAt,
2784
+ attachmentBinding: {
2785
+ ...slot.attachmentBinding,
2786
+ sessionId: slot.attachmentBinding.sessionId
2787
+ ? { ...slot.attachmentBinding.sessionId }
2788
+ : null,
2789
+ },
2790
+ incarnationStatus: slot.permanentlyFenced || slot.attachmentRetryBinding
2791
+ ? 'fenced'
2792
+ : slot.session
2793
+ ? 'running'
2794
+ : 'absent',
2795
+ incarnationGeneration: slot.incarnationGeneration,
2796
+ retirementPending: slot.retirementPending,
2797
+ };
2798
+ }
2799
+
2800
+ private assertCapabilityBinding(
2801
+ slot: IResourceSlot,
2802
+ expected: Pick<
2803
+ TIssueBrowserCapabilityRequest,
2804
+ 'attachmentAuthorityId' | 'attachmentRevision' | 'role' | 'sessionId'
2805
+ >,
2806
+ ): void {
2807
+ const binding = slot.attachmentBinding;
2808
+ if (
2809
+ binding.attachmentAuthorityId !== expected.attachmentAuthorityId
2810
+ || binding.attachmentRevision !== expected.attachmentRevision
2811
+ ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2812
+ if (expected.role === 'agent') {
2813
+ if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
2814
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2815
+ }
2816
+ } else if (expected.sessionId !== undefined) {
2817
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2818
+ }
2819
+ }
2820
+
2821
+ private assertCapabilityStillAttached(
2822
+ capability: TCapabilityRecord,
2823
+ slot: IResourceSlot,
2824
+ ): void {
2825
+ if (
2826
+ capability.projectId !== slot.projectId
2827
+ || capability.browserResourceId !== slot.browserResourceId
2828
+ ) throw new BrowserRuntimeError('CAPABILITY_INVALID');
2829
+ try {
2830
+ this.assertCapabilityBinding(slot, capability);
2831
+ } catch {
2832
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
2833
+ }
2834
+ }
2835
+
2836
+ private validateExpectedCapabilityBinding(
2837
+ value: TBrowserCapabilityAuthorizationRequest,
2838
+ ): TBrowserCapabilityAuthorizationRequest {
2839
+ if (!value || typeof value !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
2840
+ const role = this.validateRole(value.role);
2841
+ const source = this.validateSource(value.source);
2842
+ const sessionId = value.sessionId === undefined
2843
+ ? undefined
2844
+ : this.validateQualifiedSessionId(value.sessionId);
2845
+ const scopeId = value.scopeId === undefined
2846
+ ? undefined
2847
+ : this.validateIdentifier(value.scopeId, 'scopeId');
2848
+ const channelId = value.channelId === undefined
2849
+ ? undefined
2850
+ : this.validateIdentifier(value.channelId, 'channelId');
2851
+ if (
2852
+ (role === 'agent' && !sessionId)
2853
+ || (role === 'human' && sessionId !== undefined)
2854
+ || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
2855
+ || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
2856
+ || sessionId?.harnessId !== 'opencode'))
2857
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
2858
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
2859
+ const base = {
2860
+ projectId: this.validateIdentifier(value.projectId, 'projectId'),
2861
+ browserResourceId: this.validateResourceId(value.browserResourceId),
2862
+ attachmentAuthorityId: this.validateIdentifier(
2863
+ value.attachmentAuthorityId,
2864
+ 'attachmentAuthorityId',
2865
+ ),
2866
+ attachmentRevision: validateInteger(
2867
+ value.attachmentRevision,
2868
+ 'attachmentRevision',
2869
+ 0,
2870
+ Number.MAX_SAFE_INTEGER,
2871
+ ),
2872
+ actorId: this.validateIdentifier(value.actorId, 'actorId'),
2873
+ peerId: this.validateIdentifier(value.peerId, 'peerId'),
2874
+ };
2875
+ if (role === 'human' && source === 'human') {
2876
+ return { ...base, role, source };
2877
+ }
2878
+ if (role === 'agent' && source === 'flex' && sessionId?.harnessId === 'flex') {
2879
+ return { ...base, role, source, sessionId, scopeId: scopeId!, channelId: channelId! };
2880
+ }
2881
+ if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
2882
+ return { ...base, role, source, sessionId };
2883
+ }
2884
+ throw new BrowserRuntimeError('INVALID_INPUT');
2885
+ }
2886
+
2887
+ private describeCapabilityIdentity(
2888
+ capability: TBrowserCapabilityDescriptor,
2889
+ ): TBrowserCapabilityAuthorizationRequest {
2890
+ const base = {
2891
+ projectId: capability.projectId,
2892
+ browserResourceId: capability.browserResourceId,
2893
+ attachmentAuthorityId: capability.attachmentAuthorityId,
2894
+ attachmentRevision: capability.attachmentRevision,
2895
+ actorId: capability.actorId,
2896
+ peerId: capability.peerId,
2897
+ };
2898
+ if (capability.role === 'human' && capability.source === 'human') {
2899
+ return { ...base, role: capability.role, source: capability.source };
2900
+ }
2901
+ if (
2902
+ capability.role === 'agent'
2903
+ && capability.source === 'flex'
2904
+ && capability.sessionId?.harnessId === 'flex'
2905
+ ) {
2906
+ return {
2907
+ ...base,
2908
+ role: capability.role,
2909
+ source: capability.source,
2910
+ sessionId: capability.sessionId,
2911
+ scopeId: capability.scopeId!,
2912
+ channelId: capability.channelId!,
2913
+ };
2914
+ }
2915
+ if (
2916
+ capability.role === 'agent'
2917
+ && capability.source === 'mcp'
2918
+ && capability.sessionId?.harnessId === 'opencode'
2919
+ ) {
2920
+ return {
2921
+ ...base,
2922
+ role: capability.role,
2923
+ source: capability.source,
2924
+ sessionId: capability.sessionId,
2925
+ };
2926
+ }
2927
+ throw new BrowserRuntimeError('CAPABILITY_INVALID');
2928
+ }
2929
+
2930
+ private capabilityIdentitiesEqual(
2931
+ left: TBrowserCapabilityAuthorizationRequest,
2932
+ right: TBrowserCapabilityAuthorizationRequest,
2933
+ ): boolean {
2934
+ return left.projectId === right.projectId
2935
+ && left.browserResourceId === right.browserResourceId
2936
+ && left.attachmentAuthorityId === right.attachmentAuthorityId
2937
+ && left.attachmentRevision === right.attachmentRevision
2938
+ && left.actorId === right.actorId
2939
+ && left.role === right.role
2940
+ && left.peerId === right.peerId
2941
+ && left.source === right.source
2942
+ && left.scopeId === right.scopeId
2943
+ && left.channelId === right.channelId
2944
+ && this.sessionsEqual(left.sessionId, right.sessionId);
2945
+ }
2946
+
2947
+ private capabilitiesForResource(slot: IResourceSlot): TCapabilityRecord[] {
2948
+ return [...this.capabilitiesById.values()].filter((record) => (
2949
+ record.projectId === slot.projectId
2950
+ && record.browserResourceId === slot.browserResourceId
2951
+ ));
2952
+ }
2953
+
2954
+ private reserveLaunch(slot: IResourceSlot): void {
2955
+ const running = [...this.slots.values()].filter((candidate) => Boolean(candidate.session)).length;
2956
+ const runningForProject = [...this.slots.values()].filter((candidate) => (
2957
+ candidate.projectId === slot.projectId && candidate.session
2958
+ )).length;
2959
+ const pendingForProject = this.pendingLaunchesByProject.get(slot.projectId) ?? 0;
2960
+ if (
2961
+ running + this.pendingLaunches >= this.options.maxRunningResources
2962
+ || runningForProject + pendingForProject >= this.options.maxRunningResourcesPerProject
2963
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
2964
+ this.pendingLaunches += 1;
2965
+ this.pendingLaunchesByProject.set(slot.projectId, pendingForProject + 1);
2966
+ }
2967
+
2968
+ private releaseLaunchReservation(slot: IResourceSlot): void {
2969
+ this.pendingLaunches = Math.max(0, this.pendingLaunches - 1);
2970
+ const remaining = (this.pendingLaunchesByProject.get(slot.projectId) ?? 1) - 1;
2971
+ if (remaining > 0) this.pendingLaunchesByProject.set(slot.projectId, remaining);
2972
+ else this.pendingLaunchesByProject.delete(slot.projectId);
2973
+ }
2974
+
1961
2975
  private validateRole(value: unknown): TBrowserActorRole {
1962
2976
  if (value !== 'human' && value !== 'agent') throw new BrowserRuntimeError('INVALID_INPUT');
1963
2977
  return value;
@@ -1977,7 +2991,7 @@ export class BrowserRuntime {
1977
2991
  this.cleanupOperations.add(observed);
1978
2992
  }
1979
2993
 
1980
- private async emitAudit(event: IBrowserRuntimeAuditEvent): Promise<void> {
2994
+ private async emitAudit(event: TBrowserRuntimeAuditEvent): Promise<void> {
1981
2995
  if (!this.options.audit) return;
1982
2996
  const controller = new AbortController();
1983
2997
  const operation = Promise.resolve().then(() => this.options.audit!(event, controller.signal));
@@ -2002,6 +3016,10 @@ export class BrowserRuntimeLease {
2002
3016
  return this.record.leaseId;
2003
3017
  }
2004
3018
 
3019
+ public get capabilityId(): string {
3020
+ return this.record.capability.capabilityId;
3021
+ }
3022
+
2005
3023
  public get role(): TBrowserActorRole {
2006
3024
  return this.record.role;
2007
3025
  }
@@ -2010,6 +3028,18 @@ export class BrowserRuntimeLease {
2010
3028
  return this.record.capability.projectId;
2011
3029
  }
2012
3030
 
3031
+ public get browserResourceId(): string {
3032
+ return this.record.capability.browserResourceId;
3033
+ }
3034
+
3035
+ public get attachmentAuthorityId(): string {
3036
+ return this.record.capability.attachmentAuthorityId;
3037
+ }
3038
+
3039
+ public get attachmentRevision(): number {
3040
+ return this.record.capability.attachmentRevision;
3041
+ }
3042
+
2013
3043
  public getState(): IBrowserRuntimeState {
2014
3044
  return this.runtime.getLeaseState(this.record);
2015
3045
  }