@modelprofile.com/browser-runtime 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,19 +10,26 @@ 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,
19
22
  IBrowserRuntimeFrameSubscription,
20
23
  IBrowserRuntimeOperationOptions,
21
24
  IBrowserRuntimeOptions,
25
+ IBrowserResourceRegistration,
22
26
  IBrowserRuntimeState,
23
27
  IBrowserScreenshotResult,
24
- IIssueBrowserCapabilityRequest,
25
- IIssuedBrowserCapability,
28
+ ICreateBrowserResourceRequest,
29
+ TQualifiedBrowserSessionId,
30
+ IRegisterBrowserResourceRequest,
31
+ TIssueBrowserCapabilityRequest,
32
+ TIssuedBrowserCapability,
26
33
  ILiveBrowserSessionLike,
27
34
  TBrowserActorRole,
28
35
  TBrowserAgentAction,
@@ -52,26 +59,35 @@ interface INormalizedRuntimeOptions {
52
59
  ) => ILiveBrowserSessionLike;
53
60
  confinementProbe: (context: IBrowserConfinementProbeContext) => Promise<void>;
54
61
  environment: { platform: NodeJS.Platform; uid: number | undefined };
55
- maxProjectSlots: number;
62
+ maxResources: number;
63
+ maxResourcesPerProject: number;
64
+ maxRunningResources: number;
65
+ maxRunningResourcesPerProject: number;
56
66
  maxCapabilities: number;
67
+ maxCapabilitiesPerResource: number;
57
68
  maxCapabilitiesPerProject: number;
58
69
  capabilityDefaultTtlMs: number;
59
70
  capabilityMaximumTtlMs: number;
60
71
  authorizationTimeoutMs: number;
61
- maxTabsPerProject: number;
72
+ maxTabsPerResource: number;
62
73
  operationTimeoutMs: number;
63
74
  quiescenceTimeoutMs: number;
64
75
  terminationGraceMs: number;
65
76
  terminationForceMs: number;
66
77
  auditTimeoutMs: number;
67
78
  confinementProbeTimeoutMs: number;
68
- idleRetirementMs: number;
79
+ idleTerminationMs: number;
69
80
  maxFrameBytes: number;
70
81
  frameAcknowledgementTimeoutMs: number;
71
82
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
83
+ beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
84
+ beforeIdleTermination?: IBrowserRuntimeTestingOptions['beforeIdleTermination'];
85
+ beforeFrameFailureTermination?: IBrowserRuntimeTestingOptions['beforeFrameFailureTermination'];
86
+ afterFrameFailureFence?: IBrowserRuntimeTestingOptions['afterFrameFailureFence'];
87
+ beforeFramedPeerLeasePublication?: IBrowserRuntimeTestingOptions['beforeFramedPeerLeasePublication'];
72
88
  }
73
89
 
74
- interface ICapabilityRecord extends IBrowserCapabilityDescriptor {
90
+ type TCapabilityRecord = TBrowserCapabilityDescriptor & {
75
91
  digest: plugins.Buffer;
76
92
  state: 'active' | 'revoked' | 'expired';
77
93
  expiryTimer: ReturnType<typeof setTimeout>;
@@ -82,19 +98,21 @@ interface ICapabilityRecord extends IBrowserCapabilityDescriptor {
82
98
  revocationComplete?: boolean;
83
99
  leaseCleanupComplete?: boolean;
84
100
  disconnectComplete?: boolean;
85
- }
101
+ revoking?: boolean;
102
+ };
86
103
 
87
104
  interface ILeaseRecord {
88
105
  leaseId: string;
89
- capability: ICapabilityRecord;
106
+ capability: TCapabilityRecord;
90
107
  digest: plugins.Buffer;
91
108
  peerId: string;
92
109
  role: TBrowserActorRole;
93
- slot: IProjectSlot;
110
+ slot: IResourceSlot;
94
111
  controller: AbortController;
95
112
  released: boolean;
96
113
  releasePromise?: Promise<void>;
97
114
  releaseGeneration?: number;
115
+ releaseOwnsFence?: boolean;
98
116
  }
99
117
 
100
118
  interface IOperationRecord {
@@ -116,12 +134,19 @@ interface IFrameSubscriptionRecord {
116
134
  closed: boolean;
117
135
  }
118
136
 
119
- interface IProjectSlot {
137
+ interface IResourceSlot {
120
138
  projectId: string;
139
+ browserResourceId: string;
140
+ registeredAt: number;
141
+ attachmentBinding: IBrowserAttachmentBinding;
121
142
  mutex: TransitionMutex;
122
- generation: number;
143
+ arbitrationGeneration: number;
144
+ incarnationGeneration: number;
123
145
  fencingGeneration?: number;
124
146
  permanentlyFenced: boolean;
147
+ retirementPending: boolean;
148
+ attachmentFenceCount: number;
149
+ attachmentRetryBinding?: IBrowserAttachmentBinding;
125
150
  session?: ILiveBrowserSessionLike;
126
151
  proxy?: BrowserEgressProxy;
127
152
  profileDirectory?: string;
@@ -130,21 +155,21 @@ interface IProjectSlot {
130
155
  operation?: IOperationRecord;
131
156
  frameSubscription?: IFrameSubscriptionRecord;
132
157
  idleTimer?: ReturnType<typeof setTimeout>;
158
+ lifecycleTail: Promise<void>;
159
+ lifecycleOperationCount: number;
160
+ terminationRequestGeneration: number;
161
+ terminationFenceGeneration?: number;
162
+ terminatePromise?: Promise<void>;
163
+ retirePromise?: Promise<void>;
164
+ attachmentOperations: Map<string, Promise<IBrowserResourceRegistration>>;
133
165
  }
134
166
 
135
- export interface IAcquireBrowserRuntimeLeaseOptions {
167
+ export type TAcquireBrowserRuntimeLeaseOptions = TBrowserCapabilityAuthorizationRequest & {
136
168
  capabilityToken: string;
137
- peerId: string;
138
- expectedRole?: TBrowserActorRole;
139
- expectedSource?: TBrowserCapabilitySource;
140
- scopeId?: string;
141
- sessionId?: string;
142
169
  signal?: AbortSignal;
143
- }
170
+ };
144
171
 
145
- const projectIdMaximum = 128;
146
- const actorIdMaximum = 128;
147
- const peerIdMaximum = 128;
172
+ const identifierMaximum = 256;
148
173
 
149
174
  export class BrowserRuntime {
150
175
  private readonly options: INormalizedRuntimeOptions;
@@ -152,10 +177,12 @@ export class BrowserRuntime {
152
177
  private readonly artifactRoot: string;
153
178
  private readonly lockPath: string;
154
179
  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>();
180
+ private readonly slots = new Map<string, IResourceSlot>();
181
+ private readonly retiredResourceIds = new Uint8Array(64 * 1024);
182
+ private readonly retiredResourceKeys = new Uint8Array(64 * 1024);
183
+ private readonly capabilitiesByDigest = new Map<string, TCapabilityRecord>();
184
+ private readonly capabilitiesById = new Map<string, TCapabilityRecord>();
185
+ private readonly failedRevocations = new Set<TCapabilityRecord>();
159
186
  private readonly capabilityMutex = new TransitionMutex();
160
187
  private readonly cleanupOperations = new Set<Promise<void>>();
161
188
  private readonly framedPeers = new Set<BrowserRuntimeFramedServerPeer>();
@@ -168,6 +195,9 @@ export class BrowserRuntime {
168
195
  private lifecycleController = new AbortController();
169
196
  private pendingCapabilities = 0;
170
197
  private readonly pendingCapabilitiesByProject = new Map<string, number>();
198
+ private readonly pendingCapabilitiesByResource = new Map<string, number>();
199
+ private pendingLaunches = 0;
200
+ private readonly pendingLaunchesByProject = new Map<string, number>();
171
201
 
172
202
  constructor(options: IBrowserRuntimeOptions | IBrowserRuntimeTestingOptions) {
173
203
  if (!options || typeof options !== 'object') {
@@ -220,13 +250,34 @@ export class BrowserRuntime {
220
250
  )),
221
251
  confinementProbe: testingOptions?.confinementProbe ?? runProductionConfinementProbe,
222
252
  environment,
223
- maxProjectSlots: validateOptionalInteger(
224
- options.maxProjectSlots,
225
- 'maxProjectSlots',
253
+ maxResources: validateOptionalInteger(
254
+ options.maxResources,
255
+ 'maxResources',
256
+ 1,
257
+ 4096,
258
+ 256,
259
+ ),
260
+ maxResourcesPerProject: validateOptionalInteger(
261
+ options.maxResourcesPerProject,
262
+ 'maxResourcesPerProject',
263
+ 1,
264
+ 1024,
265
+ 64,
266
+ ),
267
+ maxRunningResources: validateOptionalInteger(
268
+ options.maxRunningResources,
269
+ 'maxRunningResources',
226
270
  1,
227
271
  256,
228
272
  32,
229
273
  ),
274
+ maxRunningResourcesPerProject: validateOptionalInteger(
275
+ options.maxRunningResourcesPerProject,
276
+ 'maxRunningResourcesPerProject',
277
+ 1,
278
+ 128,
279
+ 16,
280
+ ),
230
281
  maxCapabilities: validateOptionalInteger(
231
282
  options.maxCapabilities,
232
283
  'maxCapabilities',
@@ -234,6 +285,13 @@ export class BrowserRuntime {
234
285
  4096,
235
286
  512,
236
287
  ),
288
+ maxCapabilitiesPerResource: validateOptionalInteger(
289
+ options.maxCapabilitiesPerResource,
290
+ 'maxCapabilitiesPerResource',
291
+ 1,
292
+ 512,
293
+ 64,
294
+ ),
237
295
  maxCapabilitiesPerProject: validateOptionalInteger(
238
296
  options.maxCapabilitiesPerProject,
239
297
  'maxCapabilitiesPerProject',
@@ -250,9 +308,9 @@ export class BrowserRuntime {
250
308
  120_000,
251
309
  10_000,
252
310
  ),
253
- maxTabsPerProject: validateOptionalInteger(
254
- options.maxTabsPerProject,
255
- 'maxTabsPerProject',
311
+ maxTabsPerResource: validateOptionalInteger(
312
+ options.maxTabsPerResource,
313
+ 'maxTabsPerResource',
256
314
  1,
257
315
  256,
258
316
  32,
@@ -299,9 +357,9 @@ export class BrowserRuntime {
299
357
  60_000,
300
358
  10_000,
301
359
  ),
302
- idleRetirementMs: validateOptionalInteger(
303
- options.idleRetirementMs,
304
- 'idleRetirementMs',
360
+ idleTerminationMs: validateOptionalInteger(
361
+ options.idleTerminationMs,
362
+ 'idleTerminationMs',
305
363
  1000,
306
364
  24 * 60 * 60 * 1000,
307
365
  15 * 60_000,
@@ -321,6 +379,11 @@ export class BrowserRuntime {
321
379
  10_000,
322
380
  ),
323
381
  egress: options.egress ?? {},
382
+ beforeLeasePublication: testingOptions?.beforeLeasePublication,
383
+ beforeIdleTermination: testingOptions?.beforeIdleTermination,
384
+ beforeFrameFailureTermination: testingOptions?.beforeFrameFailureTermination,
385
+ afterFrameFailureFence: testingOptions?.afterFrameFailureFence,
386
+ beforeFramedPeerLeasePublication: testingOptions?.beforeFramedPeerLeasePublication,
324
387
  };
325
388
  this.profileRoot = plugins.path.join(options.runtimeDirectory, 'profiles');
326
389
  this.artifactRoot = plugins.path.join(options.runtimeDirectory, 'artifacts');
@@ -373,21 +436,174 @@ export class BrowserRuntime {
373
436
  return this.stopPromise;
374
437
  }
375
438
 
439
+ public createResource(requestArg: ICreateBrowserResourceRequest): IBrowserResourceRegistration {
440
+ this.requireRunning();
441
+ if (!requestArg || typeof requestArg !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
442
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
443
+ const attachmentBinding = this.validateAttachmentBinding(requestArg.attachmentBinding);
444
+ let browserResourceId: string;
445
+ do {
446
+ browserResourceId = randomId(24);
447
+ } while (this.slots.has(this.resourceKey(projectId, browserResourceId)));
448
+ return this.registerResource({ projectId, browserResourceId, attachmentBinding });
449
+ }
450
+
451
+ public registerResource(
452
+ requestArg: IRegisterBrowserResourceRequest,
453
+ ): IBrowserResourceRegistration {
454
+ this.requireRunning();
455
+ if (!requestArg || typeof requestArg !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
456
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
457
+ const browserResourceId = this.validateResourceId(requestArg.browserResourceId);
458
+ const attachmentBinding = this.validateAttachmentBinding(requestArg.attachmentBinding);
459
+ const key = this.resourceKey(projectId, browserResourceId);
460
+ if (this.hasRetiredTombstone(this.retiredResourceIds, browserResourceId)) {
461
+ throw new BrowserRuntimeError('RESOURCE_RETIRED');
462
+ }
463
+ const existing = this.slots.get(key);
464
+ if (existing) {
465
+ if (!this.bindingsEqual(existing.attachmentBinding, attachmentBinding)) {
466
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
467
+ }
468
+ return this.describeResource(existing);
469
+ }
470
+ if ([...this.slots.values()].some((slot) => (
471
+ slot.browserResourceId === browserResourceId && slot.projectId !== projectId
472
+ ))) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
473
+ const projectResources = [...this.slots.values()].filter((slot) => (
474
+ slot.projectId === projectId
475
+ )).length;
476
+ if (
477
+ this.slots.size >= this.options.maxResources
478
+ || projectResources >= this.options.maxResourcesPerProject
479
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
480
+ const slot: IResourceSlot = {
481
+ projectId,
482
+ browserResourceId,
483
+ registeredAt: Date.now(),
484
+ attachmentBinding,
485
+ mutex: new TransitionMutex(),
486
+ arbitrationGeneration: 0,
487
+ incarnationGeneration: 0,
488
+ permanentlyFenced: false,
489
+ retirementPending: false,
490
+ attachmentFenceCount: 0,
491
+ lifecycleTail: Promise.resolve(),
492
+ lifecycleOperationCount: 0,
493
+ terminationRequestGeneration: 0,
494
+ attachmentOperations: new Map(),
495
+ };
496
+ this.slots.set(key, slot);
497
+ return this.describeResource(slot);
498
+ }
499
+
500
+ public listResources(): IBrowserResourceRegistration[] {
501
+ this.requireRunning();
502
+ return [...this.slots.values()].map((slot) => this.describeResource(slot));
503
+ }
504
+
505
+ public async applyAttachmentBinding(
506
+ requestArg: IApplyBrowserAttachmentBindingRequest,
507
+ ): Promise<IBrowserResourceRegistration> {
508
+ this.requireRunning();
509
+ const slot = this.resolveResourceRequest(requestArg);
510
+ const binding = this.validateAttachmentBinding(requestArg.attachmentBinding);
511
+ const operationKey = this.attachmentBindingKey(binding);
512
+ const existingOperation = slot.attachmentOperations.get(operationKey);
513
+ if (existingOperation) return existingOperation;
514
+ if (slot.attachmentOperations.size > 0) throw new BrowserRuntimeError('BUSY');
515
+ if (
516
+ slot.attachmentRetryBinding
517
+ && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
518
+ ) throw new BrowserRuntimeError('BUSY');
519
+ const currentBinding = slot.attachmentBinding;
520
+ if (binding.attachmentRevision < currentBinding.attachmentRevision) {
521
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
522
+ }
523
+ if (
524
+ binding.attachmentRevision === currentBinding.attachmentRevision
525
+ && !slot.attachmentRetryBinding
526
+ ) {
527
+ if (!this.bindingsEqual(binding, currentBinding)) {
528
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
529
+ }
530
+ return this.describeResource(slot);
531
+ }
532
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
533
+ slot.attachmentFenceCount += 1;
534
+ const operation = this.enqueueResourceLifecycle(slot, async () => (
535
+ this.applyAttachmentBindingInternal(slot, binding)
536
+ )).finally(() => {
537
+ slot.attachmentFenceCount = Math.max(0, slot.attachmentFenceCount - 1);
538
+ slot.attachmentOperations.delete(operationKey);
539
+ });
540
+ slot.attachmentOperations.set(operationKey, operation);
541
+ return operation;
542
+ }
543
+
544
+ public terminateResource(projectIdArg: string, browserResourceIdArg: string): Promise<void> {
545
+ this.requireRunning();
546
+ const slot = this.getRegisteredSlot(projectIdArg, browserResourceIdArg);
547
+ if (slot.retirePromise) return slot.retirePromise;
548
+ if (slot.terminatePromise) return slot.terminatePromise;
549
+ const terminationFenceGeneration = slot.terminationFenceGeneration
550
+ ?? ++slot.terminationRequestGeneration;
551
+ slot.terminationFenceGeneration = terminationFenceGeneration;
552
+ const lifecycleOperation = this.enqueueResourceLifecycle(slot, async () => {
553
+ await this.terminateRegisteredResourceInternal(slot, false);
554
+ });
555
+ const operation = lifecycleOperation.then(() => {
556
+ if (slot.terminationFenceGeneration === terminationFenceGeneration) {
557
+ slot.terminationFenceGeneration = undefined;
558
+ }
559
+ }).finally(() => {
560
+ if (slot.terminatePromise === operation) slot.terminatePromise = undefined;
561
+ });
562
+ slot.terminatePromise = operation;
563
+ return operation;
564
+ }
565
+
566
+ public retireResource(projectIdArg: string, browserResourceIdArg: string): Promise<void> {
567
+ this.requireRunning();
568
+ const slot = this.getRegisteredSlot(projectIdArg, browserResourceIdArg);
569
+ if (slot.retirePromise) return slot.retirePromise;
570
+ slot.retirementPending = true;
571
+ this.addRetiredTombstone(this.retiredResourceKeys, this.resourceKey(
572
+ slot.projectId,
573
+ slot.browserResourceId,
574
+ ));
575
+ this.addRetiredTombstone(this.retiredResourceIds, slot.browserResourceId);
576
+ const operation = this.enqueueResourceLifecycle(slot, async () => {
577
+ await this.retireRegisteredResourceInternal(slot);
578
+ }).finally(() => {
579
+ if (slot.retirePromise === operation) slot.retirePromise = undefined;
580
+ });
581
+ slot.retirePromise = operation;
582
+ return operation;
583
+ }
584
+
376
585
  public async issueCapability(
377
- requestArg: IIssueBrowserCapabilityRequest,
378
- ): Promise<IIssuedBrowserCapability> {
586
+ requestArg: TIssueBrowserCapabilityRequest,
587
+ ): Promise<TIssuedBrowserCapability> {
379
588
  this.requireRunning();
380
589
  if (!requestArg || typeof requestArg !== 'object') {
381
590
  throw new BrowserRuntimeError('INVALID_INPUT');
382
591
  }
383
- const projectId = validateBoundedString(
384
- requestArg.projectId,
385
- 'projectId',
386
- 1,
387
- projectIdMaximum,
592
+ const projectId = this.validateIdentifier(requestArg.projectId, 'projectId');
593
+ const browserResourceId = this.validateResourceId(requestArg.browserResourceId);
594
+ const slot = this.getRegisteredSlot(projectId, browserResourceId);
595
+ const attachmentAuthorityId = this.validateIdentifier(
596
+ requestArg.attachmentAuthorityId,
597
+ 'attachmentAuthorityId',
388
598
  );
389
- const actorId = validateBoundedString(requestArg.actorId, 'actorId', 1, actorIdMaximum);
390
- const peerId = validateBoundedString(requestArg.peerId, 'peerId', 1, peerIdMaximum);
599
+ const attachmentRevision = validateInteger(
600
+ requestArg.attachmentRevision,
601
+ 'attachmentRevision',
602
+ 0,
603
+ Number.MAX_SAFE_INTEGER,
604
+ );
605
+ const actorId = this.validateIdentifier(requestArg.actorId, 'actorId');
606
+ const peerId = this.validateIdentifier(requestArg.peerId, 'peerId');
391
607
  const role = this.validateRole(requestArg.role);
392
608
  const source = this.validateSource(requestArg.source);
393
609
  if (
@@ -402,14 +618,41 @@ export class BrowserRuntime {
402
618
  const disconnect = requestArg.disconnect;
403
619
  const scopeId = requestArg.scopeId === undefined
404
620
  ? undefined
405
- : validateBoundedString(requestArg.scopeId, 'scopeId', 1, 128);
621
+ : this.validateIdentifier(requestArg.scopeId, 'scopeId');
622
+ const channelId = requestArg.channelId === undefined
623
+ ? undefined
624
+ : this.validateIdentifier(requestArg.channelId, 'channelId');
406
625
  const sessionId = requestArg.sessionId === undefined
407
626
  ? undefined
408
- : validateBoundedString(requestArg.sessionId, 'sessionId', 1, 128);
627
+ : this.validateQualifiedSessionId(requestArg.sessionId);
409
628
  if (
410
- (source === 'flex' && (!scopeId || !sessionId))
411
- || (source !== 'flex' && (scopeId !== undefined || sessionId !== undefined))
629
+ (role === 'agent' && !sessionId)
630
+ || (role === 'human' && sessionId !== undefined)
631
+ || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
632
+ || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
633
+ || sessionId?.harnessId !== 'opencode'))
634
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
412
635
  ) throw new BrowserRuntimeError('INVALID_INPUT');
636
+ const authority = this.validateExpectedCapabilityBinding({
637
+ projectId,
638
+ browserResourceId,
639
+ attachmentAuthorityId,
640
+ attachmentRevision,
641
+ actorId,
642
+ role,
643
+ peerId,
644
+ source,
645
+ ...(scopeId ? { scopeId } : {}),
646
+ ...(channelId ? { channelId } : {}),
647
+ ...(sessionId ? { sessionId } : {}),
648
+ } as TBrowserCapabilityAuthorizationRequest);
649
+ this.assertCapabilityBinding(slot, {
650
+ attachmentAuthorityId,
651
+ attachmentRevision,
652
+ role,
653
+ sessionId,
654
+ });
655
+ this.assertSlotAvailable(slot);
413
656
  const expiresInMs = requestArg.expiresInMs === undefined
414
657
  ? this.options.capabilityDefaultTtlMs
415
658
  : validateInteger(
@@ -420,16 +663,21 @@ export class BrowserRuntime {
420
663
  );
421
664
 
422
665
  const lifecycleEpoch = this.lifecycleEpoch;
666
+ const resourceKey = this.resourceKey(projectId, browserResourceId);
423
667
  const pendingForProject = this.pendingCapabilitiesByProject.get(projectId) ?? 0;
424
- const activeForProject = [...this.capabilitiesById.values()].filter((record) => (
425
- record.projectId === projectId && record.state === 'active'
668
+ const pendingForResource = this.pendingCapabilitiesByResource.get(resourceKey) ?? 0;
669
+ const recordedForProject = [...this.capabilitiesById.values()].filter((record) => (
670
+ record.projectId === projectId
426
671
  )).length;
672
+ const recordedForResource = this.capabilitiesForResource(slot).length;
427
673
  if (
428
674
  this.pendingCapabilities + this.capabilitiesById.size >= this.options.maxCapabilities
429
- || pendingForProject + activeForProject >= this.options.maxCapabilitiesPerProject
675
+ || pendingForProject + recordedForProject >= this.options.maxCapabilitiesPerProject
676
+ || pendingForResource + recordedForResource >= this.options.maxCapabilitiesPerResource
430
677
  ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
431
678
  this.pendingCapabilities += 1;
432
679
  this.pendingCapabilitiesByProject.set(projectId, pendingForProject + 1);
680
+ this.pendingCapabilitiesByResource.set(resourceKey, pendingForResource + 1);
433
681
  let authorized = false;
434
682
  const authorizationController = new AbortController();
435
683
  const authorizationSignal = AbortSignal.any([
@@ -437,15 +685,10 @@ export class BrowserRuntime {
437
685
  this.lifecycleController.signal,
438
686
  ]);
439
687
  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));
688
+ const authorization = Promise.resolve(this.options.authorizeCapability(
689
+ authority,
690
+ authorizationSignal,
691
+ ));
449
692
  const result = await waitBounded(authorization, this.options.authorizationTimeoutMs);
450
693
  if (!result.settled) {
451
694
  authorizationController.abort(new BrowserRuntimeError('TIMEOUT'));
@@ -460,25 +703,48 @@ export class BrowserRuntime {
460
703
  const remaining = (this.pendingCapabilitiesByProject.get(projectId) ?? 1) - 1;
461
704
  if (remaining > 0) this.pendingCapabilitiesByProject.set(projectId, remaining);
462
705
  else this.pendingCapabilitiesByProject.delete(projectId);
706
+ const resourceRemaining = (this.pendingCapabilitiesByResource.get(resourceKey) ?? 1) - 1;
707
+ if (resourceRemaining > 0) {
708
+ this.pendingCapabilitiesByResource.set(resourceKey, resourceRemaining);
709
+ } else {
710
+ this.pendingCapabilitiesByResource.delete(resourceKey);
711
+ }
463
712
  }
464
713
  if (authorized !== true) throw new BrowserRuntimeError('AUTHORIZATION_DENIED');
465
714
  this.requireRunning();
466
715
  if (this.lifecycleEpoch !== lifecycleEpoch) throw new BrowserRuntimeError('ABORTED');
716
+ const slotAfterAuthorization = this.getRegisteredSlot(projectId, browserResourceId);
717
+ this.assertCapabilityBinding(slotAfterAuthorization, {
718
+ attachmentAuthorityId,
719
+ attachmentRevision,
720
+ role,
721
+ sessionId,
722
+ });
723
+ this.assertSlotAvailable(slotAfterAuthorization);
467
724
 
468
725
  const release = await this.capabilityMutex.acquire();
469
726
  try {
470
727
  this.requireRunning();
471
728
  if (this.lifecycleEpoch !== lifecycleEpoch) throw new BrowserRuntimeError('ABORTED');
729
+ const currentSlot = this.getRegisteredSlot(projectId, browserResourceId);
730
+ this.assertCapabilityBinding(currentSlot, {
731
+ attachmentAuthorityId,
732
+ attachmentRevision,
733
+ role,
734
+ sessionId,
735
+ });
736
+ this.assertSlotAvailable(currentSlot);
472
737
  const activeProjectCapabilities = [...this.capabilitiesById.values()].filter((record) => (
473
- record.projectId === projectId && record.state === 'active'
738
+ record.projectId === projectId
474
739
  )).length;
740
+ const activeResourceCapabilities = this.capabilitiesForResource(currentSlot).length;
475
741
  if (
476
742
  this.capabilitiesById.size >= this.options.maxCapabilities
477
743
  || activeProjectCapabilities >= this.options.maxCapabilitiesPerProject
744
+ || activeResourceCapabilities >= this.options.maxCapabilitiesPerResource
478
745
  ) {
479
746
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
480
747
  }
481
- this.getOrCreateSlot(projectId);
482
748
  let capabilityToken: string;
483
749
  let digest: plugins.Buffer;
484
750
  let digestKey: string;
@@ -489,20 +755,14 @@ export class BrowserRuntime {
489
755
  } while (this.capabilitiesByDigest.has(digestKey));
490
756
  const capabilityId = randomId(18);
491
757
  const expiresAt = Date.now() + expiresInMs;
492
- let record: ICapabilityRecord;
758
+ let record: TCapabilityRecord;
493
759
  const expiryTimer = setTimeout(() => {
494
760
  if (record.state === 'active') record.state = 'expired';
495
761
  this.trackCleanup(this.revokeCapabilityRecord(record));
496
762
  }, expiresInMs);
497
763
  record = {
764
+ ...authority,
498
765
  capabilityId,
499
- projectId,
500
- actorId,
501
- role,
502
- peerId,
503
- source,
504
- ...(scopeId ? { scopeId } : {}),
505
- ...(sessionId ? { sessionId } : {}),
506
766
  expiresAt,
507
767
  digest,
508
768
  state: 'active',
@@ -513,14 +773,8 @@ export class BrowserRuntime {
513
773
  this.capabilitiesByDigest.set(digestKey, record);
514
774
  this.capabilitiesById.set(capabilityId, record);
515
775
  return {
776
+ ...authority,
516
777
  capabilityId,
517
- projectId,
518
- actorId,
519
- role,
520
- peerId,
521
- source,
522
- ...(scopeId ? { scopeId } : {}),
523
- ...(sessionId ? { sessionId } : {}),
524
778
  expiresAt,
525
779
  capabilityToken,
526
780
  };
@@ -530,25 +784,13 @@ export class BrowserRuntime {
530
784
  }
531
785
 
532
786
  public async acquireLease(
533
- optionsArg: IAcquireBrowserRuntimeLeaseOptions,
787
+ optionsArg: TAcquireBrowserRuntimeLeaseOptions,
534
788
  ): Promise<BrowserRuntimeLease> {
535
789
  this.requireRunning();
536
790
  if (!optionsArg || typeof optionsArg !== 'object') {
537
791
  throw new BrowserRuntimeError('INVALID_INPUT');
538
792
  }
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);
793
+ const expected = this.validateExpectedCapabilityBinding(optionsArg);
552
794
  this.throwIfAcquisitionAborted(optionsArg.signal);
553
795
  const capabilityToken = validateBoundedString(
554
796
  optionsArg.capabilityToken,
@@ -559,13 +801,10 @@ export class BrowserRuntime {
559
801
  const digest = digestToken(capabilityToken);
560
802
  const capability = this.resolveCapability(
561
803
  digest,
562
- peerId,
563
- expectedRole,
564
- expectedSource,
565
- scopeId,
566
- sessionId,
804
+ expected,
567
805
  );
568
- const slot = this.getOrCreateSlot(capability.projectId);
806
+ const slot = this.getRegisteredSlot(capability.projectId, capability.browserResourceId);
807
+ this.assertCapabilityStillAttached(capability, slot);
569
808
  return capability.role === 'agent'
570
809
  ? this.acquireAgentLease(slot, capability, digest, optionsArg)
571
810
  : this.acquireHumanLease(slot, capability, digest, optionsArg);
@@ -573,14 +812,16 @@ export class BrowserRuntime {
573
812
 
574
813
  public authorizeCapability(
575
814
  capabilityTokenArg: string,
576
- peerIdArg: string,
577
- role: TBrowserActorRole,
578
- source: TBrowserCapabilitySource,
579
- ): IBrowserCapabilityDescriptor {
815
+ expectedArg: TBrowserCapabilityAuthorizationRequest,
816
+ ): TBrowserCapabilityDescriptor {
580
817
  this.requireRunning();
581
818
  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);
819
+ const expected = this.validateExpectedCapabilityBinding(expectedArg);
820
+ const record = this.resolveCapability(digestToken(token), expected);
821
+ this.assertCapabilityStillAttached(
822
+ record,
823
+ this.getRegisteredSlot(record.projectId, record.browserResourceId),
824
+ );
584
825
  return this.describeCapability(record);
585
826
  }
586
827
 
@@ -596,8 +837,22 @@ export class BrowserRuntime {
596
837
  await this.revokeCapabilityRecord(capability);
597
838
  }
598
839
 
840
+ /** @internal */
841
+ public async revokeFramedCapability(capabilityIdArg: string): Promise<void> {
842
+ const capabilityId = validateBoundedString(capabilityIdArg, 'capabilityId', 1, 128);
843
+ const capability = this.capabilitiesById.get(capabilityId);
844
+ if (!capability) return;
845
+ if (capability.source !== 'flex') throw new BrowserRuntimeError('CAPABILITY_INVALID');
846
+ await this.revokeCapabilityRecord(capability, false);
847
+ }
848
+
849
+ /** @internal */
850
+ public async beforeFramedPeerLeasePublication(): Promise<void> {
851
+ await this.options.beforeFramedPeerLeasePublication?.();
852
+ }
853
+
599
854
  public async disconnectPeer(peerIdArg: string): Promise<void> {
600
- const peerId = validateBoundedString(peerIdArg, 'peerId', 1, peerIdMaximum);
855
+ const peerId = this.validateIdentifier(peerIdArg, 'peerId');
601
856
  const matching = [...this.capabilitiesById.values()].filter((record) => (
602
857
  record.peerId === peerId && record.state === 'active'
603
858
  ));
@@ -610,27 +865,29 @@ export class BrowserRuntime {
610
865
  if (errors.length > 0) throw new AggregateError(errors, 'Peer cleanup is incomplete');
611
866
  }
612
867
 
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
868
  public attachTrustedFramedPeer(
629
869
  options: IAttachTrustedFramedPeerOptions,
630
870
  ): BrowserRuntimeFramedServerPeer {
631
871
  this.requireRunning();
632
- const peerId = validateBoundedString(options.peerId, 'peerId', 1, peerIdMaximum);
633
- if ([...this.framedPeers].some((peer) => peer.peerId === peerId)) {
872
+ const trustedBinding = this.validateExpectedCapabilityBinding(options);
873
+ if (trustedBinding.source !== 'flex') throw new BrowserRuntimeError('INVALID_INPUT');
874
+ const slot = this.getRegisteredSlot(
875
+ trustedBinding.projectId,
876
+ trustedBinding.browserResourceId,
877
+ );
878
+ this.assertCapabilityBinding(slot, trustedBinding);
879
+ this.assertSlotAvailable(slot);
880
+ if (
881
+ slot.lifecycleOperationCount > 0
882
+ || slot.terminatePromise
883
+ || slot.retirePromise
884
+ || slot.attachmentOperations.size > 0
885
+ ) throw new BrowserRuntimeError('BUSY');
886
+ const peerId = trustedBinding.peerId;
887
+ const channelId = trustedBinding.channelId;
888
+ if ([...this.framedPeers].some((peer) => (
889
+ peer.peerId === peerId && peer.channelId === channelId
890
+ ))) {
634
891
  throw new BrowserRuntimeError('BUSY');
635
892
  }
636
893
  const peer = new BrowserRuntimeFramedServerPeer(this, options, () => {
@@ -649,7 +906,7 @@ export class BrowserRuntime {
649
906
  public getLeaseState(lease: ILeaseRecord): IBrowserRuntimeState {
650
907
  this.requireValidLease(lease);
651
908
  const session = lease.slot.session!;
652
- return this.projectState(session.getState());
909
+ return this.resourceState(session.getState());
653
910
  }
654
911
 
655
912
  /** @internal */
@@ -673,11 +930,11 @@ export class BrowserRuntime {
673
930
  ): Promise<IBrowserRuntimeState> {
674
931
  this.requireHuman(lease);
675
932
  return this.runOperation(lease, 'createTab', operationOptions, async (signal, session) => {
676
- if (session.getState().tabs.length >= this.options.maxTabsPerProject) {
933
+ if (session.getState().tabs.length >= this.options.maxTabsPerResource) {
677
934
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
678
935
  }
679
936
  await session.createTab(options, { signal });
680
- return this.projectState(session.getState());
937
+ return this.resourceState(session.getState());
681
938
  });
682
939
  }
683
940
 
@@ -691,7 +948,7 @@ export class BrowserRuntime {
691
948
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
692
949
  return this.runOperation(lease, 'activateTab', operationOptions, async (signal, session) => {
693
950
  await session.activateTab(tabId, { signal });
694
- return this.projectState(session.getState());
951
+ return this.resourceState(session.getState());
695
952
  });
696
953
  }
697
954
 
@@ -705,7 +962,7 @@ export class BrowserRuntime {
705
962
  const tabId = validateBoundedString(tabIdArg, 'tabId', 1, 128);
706
963
  return this.runOperation(lease, 'closeTab', operationOptions, async (signal, session) => {
707
964
  await session.closeTab(tabId, { signal });
708
- return this.projectState(session.getState());
965
+ return this.resourceState(session.getState());
709
966
  });
710
967
  }
711
968
 
@@ -719,7 +976,7 @@ export class BrowserRuntime {
719
976
  this.requireHuman(lease);
720
977
  return this.runOperation(lease, action, operationOptions, async (signal, session) => {
721
978
  await session[action](options, { signal });
722
- return this.projectState(session.getState());
979
+ return this.resourceState(session.getState());
723
980
  });
724
981
  }
725
982
 
@@ -790,7 +1047,7 @@ export class BrowserRuntime {
790
1047
  slot.frameSubscription = subscription;
791
1048
  this.pushToSubscription(subscription, {
792
1049
  type: 'state',
793
- state: this.projectState(slot.session!.getState()),
1050
+ state: this.resourceState(slot.session!.getState()),
794
1051
  });
795
1052
  } finally {
796
1053
  release();
@@ -832,20 +1089,14 @@ export class BrowserRuntime {
832
1089
  clearTimeout(subscription.outstanding.timer);
833
1090
  subscription.outstanding = undefined;
834
1091
  const session = slot.session!;
1092
+ const incarnationGeneration = slot.incarnationGeneration;
835
1093
  const operation = session.acknowledgeFrame(acknowledgement);
836
1094
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
837
1095
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
838
1096
  if (!result.settled || !result.value.accepted) {
839
1097
  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
- }
1098
+ await this.options.beforeFrameFailureTermination?.();
1099
+ await this.handleFrameFailure(slot, session, incarnationGeneration, lease);
849
1100
  return false;
850
1101
  }
851
1102
  return true;
@@ -858,14 +1109,22 @@ export class BrowserRuntime {
858
1109
  ): Promise<Uint8Array> {
859
1110
  this.requireHuman(lease);
860
1111
  this.requireValidLease(lease);
861
- return this.artifactStore.read(lease.capability.projectId, artifactId);
1112
+ return this.artifactStore.read(
1113
+ lease.capability.projectId,
1114
+ lease.capability.browserResourceId,
1115
+ artifactId,
1116
+ );
862
1117
  }
863
1118
 
864
1119
  /** @internal */
865
1120
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
866
1121
  this.requireHuman(lease);
867
1122
  this.requireValidLease(lease);
868
- await this.artifactStore.delete(lease.capability.projectId, artifactId);
1123
+ await this.artifactStore.delete(
1124
+ lease.capability.projectId,
1125
+ lease.capability.browserResourceId,
1126
+ artifactId,
1127
+ );
869
1128
  }
870
1129
 
871
1130
  /** @internal */
@@ -924,7 +1183,9 @@ export class BrowserRuntime {
924
1183
  [...revocationRecords].map((record) => this.revokeCapabilityRecord(record)),
925
1184
  );
926
1185
  const retirementResults = await Promise.allSettled(
927
- [...this.slots.keys()].map((projectId) => this.retireSlot(projectId, true)),
1186
+ [...this.slots.values()].map((slot) => this.enqueueResourceLifecycle(slot, async () => {
1187
+ await this.terminateRegisteredResourceInternal(slot, true);
1188
+ })),
928
1189
  );
929
1190
  while (this.cleanupOperations.size > 0) {
930
1191
  await Promise.allSettled([...this.cleanupOperations]);
@@ -945,6 +1206,9 @@ export class BrowserRuntime {
945
1206
  this.lockHandle = undefined;
946
1207
  this.lockHandleClosed = false;
947
1208
  }
1209
+ this.slots.clear();
1210
+ this.retiredResourceIds.fill(0);
1211
+ this.retiredResourceKeys.fill(0);
948
1212
  this.startPromise = undefined;
949
1213
  }
950
1214
 
@@ -952,55 +1216,29 @@ export class BrowserRuntime {
952
1216
  if (this.lifecycleState !== 'running') throw new BrowserRuntimeError('NOT_RUNNING');
953
1217
  }
954
1218
 
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
1219
  private async acquireAgentLease(
972
- slot: IProjectSlot,
973
- capability: ICapabilityRecord,
1220
+ slot: IResourceSlot,
1221
+ capability: TCapabilityRecord,
974
1222
  digest: plugins.Buffer,
975
- acquisition: IAcquireBrowserRuntimeLeaseOptions,
1223
+ acquisition: TAcquireBrowserRuntimeLeaseOptions,
976
1224
  ): Promise<BrowserRuntimeLease> {
977
1225
  const release = slot.mutex.tryAcquire();
978
1226
  if (!release) throw new BrowserRuntimeError('BUSY');
979
1227
  try {
980
1228
  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
- );
1229
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1230
+ this.assertCapabilityStillAttached(capability, slot);
989
1231
  if (slot.lease || slot.operation) throw new BrowserRuntimeError('BUSY');
990
1232
  await this.ensureSession(slot, acquisition.signal);
1233
+ await this.options.beforeLeasePublication?.();
991
1234
  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
- );
1235
+ this.assertSlotAvailable(slot);
1236
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1237
+ this.assertCapabilityStillAttached(capability, slot);
1000
1238
  const lease = this.createLeaseRecord(slot, capability, digest);
1001
1239
  return new BrowserRuntimeLease(this, lease);
1002
1240
  } catch (error) {
1003
- if (slot.session && !slot.lease) this.scheduleIdleRetirement(slot);
1241
+ if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1004
1242
  throw error;
1005
1243
  } finally {
1006
1244
  release();
@@ -1008,27 +1246,29 @@ export class BrowserRuntime {
1008
1246
  }
1009
1247
 
1010
1248
  private async acquireHumanLease(
1011
- slot: IProjectSlot,
1012
- capability: ICapabilityRecord,
1249
+ slot: IResourceSlot,
1250
+ capability: TCapabilityRecord,
1013
1251
  digest: plugins.Buffer,
1014
- acquisition: IAcquireBrowserRuntimeLeaseOptions,
1252
+ acquisition: TAcquireBrowserRuntimeLeaseOptions,
1015
1253
  ): Promise<BrowserRuntimeLease> {
1016
1254
  const release = await slot.mutex.acquire();
1017
1255
  let generation = 0;
1018
1256
  let preemptedLease: ILeaseRecord | undefined;
1019
1257
  try {
1020
1258
  this.assertSlotAvailable(slot);
1021
- this.resolveCapability(digest, capability.peerId, 'human', 'human');
1259
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1260
+ this.assertCapabilityStillAttached(capability, slot);
1022
1261
  this.throwIfAcquisitionAborted(acquisition.signal);
1023
1262
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1024
1263
  if (slot.lease?.role === 'human') throw new BrowserRuntimeError('BUSY');
1025
- generation = ++slot.generation;
1264
+ generation = ++slot.arbitrationGeneration;
1026
1265
  slot.fencingGeneration = generation;
1027
1266
  preemptedLease = slot.lease;
1028
1267
  if (preemptedLease) {
1029
1268
  preemptedLease.released = true;
1030
1269
  preemptedLease.controller.abort(new BrowserRuntimeError('ABORTED'));
1031
1270
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1271
+ this.notifyFramedLeaseEnded(preemptedLease);
1032
1272
  }
1033
1273
  } finally {
1034
1274
  release();
@@ -1039,7 +1279,7 @@ export class BrowserRuntime {
1039
1279
  this.throwIfAcquisitionAborted(acquisition.signal);
1040
1280
  const finalRelease = await slot.mutex.acquire();
1041
1281
  try {
1042
- if (slot.generation !== generation || slot.fencingGeneration !== generation) {
1282
+ if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1043
1283
  throw new BrowserRuntimeError('BUSY');
1044
1284
  }
1045
1285
  if (preemptedLease) {
@@ -1051,8 +1291,11 @@ export class BrowserRuntime {
1051
1291
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
1052
1292
  slot.fencingGeneration = undefined;
1053
1293
  await this.ensureSession(slot, acquisition.signal);
1294
+ await this.options.beforeLeasePublication?.();
1054
1295
  this.throwIfAcquisitionAborted(acquisition.signal);
1055
- this.resolveCapability(digest, capability.peerId, 'human', 'human');
1296
+ this.assertSlotAvailable(slot);
1297
+ this.resolveCapability(digest, this.validateExpectedCapabilityBinding(acquisition));
1298
+ this.assertCapabilityStillAttached(capability, slot);
1056
1299
  const lease = this.createLeaseRecord(slot, capability, digest);
1057
1300
  return new BrowserRuntimeLease(this, lease);
1058
1301
  } finally {
@@ -1068,7 +1311,7 @@ export class BrowserRuntime {
1068
1311
  if (slot.lease === preemptedLease) slot.lease = undefined;
1069
1312
  }
1070
1313
  if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
1071
- if (slot.session && !slot.lease) this.scheduleIdleRetirement(slot);
1314
+ if (slot.session && !slot.lease) this.scheduleIdleTermination(slot);
1072
1315
  } finally {
1073
1316
  cleanupRelease();
1074
1317
  }
@@ -1077,10 +1320,12 @@ export class BrowserRuntime {
1077
1320
  }
1078
1321
 
1079
1322
  private createLeaseRecord(
1080
- slot: IProjectSlot,
1081
- capability: ICapabilityRecord,
1323
+ slot: IResourceSlot,
1324
+ capability: TCapabilityRecord,
1082
1325
  digest: plugins.Buffer,
1083
1326
  ): ILeaseRecord {
1327
+ this.assertSlotAvailable(slot);
1328
+ this.assertCapabilityStillAttached(capability, slot);
1084
1329
  if (capability.lease || slot.lease) throw new BrowserRuntimeError('BUSY');
1085
1330
  if (slot.idleTimer) {
1086
1331
  clearTimeout(slot.idleTimer);
@@ -1101,9 +1346,10 @@ export class BrowserRuntime {
1101
1346
  return lease;
1102
1347
  }
1103
1348
 
1104
- private async ensureSession(slot: IProjectSlot, signalArg?: AbortSignal): Promise<void> {
1349
+ private async ensureSession(slot: IResourceSlot, signalArg?: AbortSignal): Promise<void> {
1105
1350
  if (slot.session) return;
1106
1351
  this.assertSlotAvailable(slot);
1352
+ this.reserveLaunch(slot);
1107
1353
  const profileDirectory = plugins.path.join(this.profileRoot, randomId(24));
1108
1354
  let profileCreated = false;
1109
1355
  let proxy: BrowserEgressProxy | undefined;
@@ -1115,6 +1361,7 @@ export class BrowserRuntime {
1115
1361
  await plugins.fsPromises.chmod(profileDirectory, 0o700);
1116
1362
  proxy = new BrowserEgressProxy({
1117
1363
  projectId: slot.projectId,
1364
+ browserResourceId: slot.browserResourceId,
1118
1365
  ...this.options.egress,
1119
1366
  });
1120
1367
  await proxy.start();
@@ -1174,7 +1421,13 @@ export class BrowserRuntime {
1174
1421
  };
1175
1422
  try {
1176
1423
  await runBounded(session.start({ signal: probeSignal }));
1177
- await runBounded(this.options.confinementProbe({ session, proxy, signal: probeSignal }));
1424
+ await runBounded(this.options.confinementProbe({
1425
+ projectId: slot.projectId,
1426
+ browserResourceId: slot.browserResourceId,
1427
+ session,
1428
+ proxy,
1429
+ signal: probeSignal,
1430
+ }));
1178
1431
  } finally {
1179
1432
  clearTimeout(timeout);
1180
1433
  }
@@ -1183,6 +1436,7 @@ export class BrowserRuntime {
1183
1436
  slot.proxy = proxy;
1184
1437
  slot.profileDirectory = profileDirectory;
1185
1438
  slot.unsubscribeSession = unsubscribe;
1439
+ slot.incarnationGeneration += 1;
1186
1440
  } catch (error) {
1187
1441
  unsubscribe?.();
1188
1442
  let confirmed = true;
@@ -1228,6 +1482,8 @@ export class BrowserRuntime {
1228
1482
  throw error instanceof BrowserRuntimeError
1229
1483
  ? error
1230
1484
  : new BrowserRuntimeError('CONFINEMENT_FAILED');
1485
+ } finally {
1486
+ this.releaseLaunchReservation(slot);
1231
1487
  }
1232
1488
  }
1233
1489
 
@@ -1264,7 +1520,7 @@ export class BrowserRuntime {
1264
1520
  this.assertSlotAvailable(slot);
1265
1521
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1266
1522
  session = slot.session!;
1267
- generation = slot.generation;
1523
+ generation = slot.arbitrationGeneration;
1268
1524
  const timeoutMs = operationOptions.timeoutMs === undefined
1269
1525
  ? this.options.operationTimeoutMs
1270
1526
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
@@ -1315,7 +1571,8 @@ export class BrowserRuntime {
1315
1571
  }
1316
1572
  if (combinedSignal.aborted) throw combinedSignal.reason;
1317
1573
  this.requireValidLease(lease);
1318
- if (slot.session !== session! || slot.generation !== generation!) {
1574
+ this.assertCapabilityStillAttached(lease.capability, slot);
1575
+ if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
1319
1576
  throw new BrowserRuntimeError('ABORTED');
1320
1577
  }
1321
1578
  return result;
@@ -1347,12 +1604,10 @@ export class BrowserRuntime {
1347
1604
  }
1348
1605
  }).catch(() => undefined);
1349
1606
  await this.emitAudit({
1607
+ ...this.describeCapabilityIdentity(lease.capability),
1350
1608
  operationId: operation!.operationId,
1351
1609
  capabilityId: lease.capability.capabilityId,
1352
- projectId: lease.capability.projectId,
1353
- actorId: lease.capability.actorId,
1354
- role: lease.role,
1355
- source: lease.capability.source,
1610
+ leaseId: lease.leaseId,
1356
1611
  action,
1357
1612
  phase,
1358
1613
  startedAt,
@@ -1376,7 +1631,7 @@ export class BrowserRuntime {
1376
1631
  }, { signal });
1377
1632
  return {
1378
1633
  action: 'navigate',
1379
- state: this.projectState(session.getState()),
1634
+ state: this.resourceState(session.getState()),
1380
1635
  } satisfies IBrowserActionStateResult;
1381
1636
  }
1382
1637
  if (action.action === 'snapshot') {
@@ -1391,7 +1646,7 @@ export class BrowserRuntime {
1391
1646
  title: truncateString(observation.title, 2048),
1392
1647
  text: truncateString(observation.text, 50_000),
1393
1648
  truncated: observation.truncated,
1394
- state: this.projectState(observation.state),
1649
+ state: this.resourceState(observation.state),
1395
1650
  } satisfies IBrowserObservationResult;
1396
1651
  }
1397
1652
  if (action.action === 'screenshot') {
@@ -1406,6 +1661,7 @@ export class BrowserRuntime {
1406
1661
  if (lease.slot.session !== session) throw new BrowserRuntimeError('ABORTED');
1407
1662
  const artifact = await this.artifactStore.store(
1408
1663
  lease.capability.projectId,
1664
+ lease.capability.browserResourceId,
1409
1665
  snapshot.mimeType,
1410
1666
  snapshot.data,
1411
1667
  );
@@ -1438,7 +1694,7 @@ export class BrowserRuntime {
1438
1694
  }
1439
1695
  return {
1440
1696
  action: action.action,
1441
- state: this.projectState(session.getState()),
1697
+ state: this.resourceState(session.getState()),
1442
1698
  } satisfies IBrowserActionStateResult;
1443
1699
  }
1444
1700
 
@@ -1456,7 +1712,7 @@ export class BrowserRuntime {
1456
1712
  };
1457
1713
  }
1458
1714
 
1459
- private projectState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
1715
+ private resourceState(state: plugins.smartpuppeteer.ILiveBrowserState): IBrowserRuntimeState {
1460
1716
  return {
1461
1717
  status: state.status,
1462
1718
  activeTabId: state.activeTabId ? truncateString(state.activeTabId, 128) : null,
@@ -1491,7 +1747,7 @@ export class BrowserRuntime {
1491
1747
  }
1492
1748
 
1493
1749
  private handleSessionEvent(
1494
- slot: IProjectSlot,
1750
+ slot: IResourceSlot,
1495
1751
  session: ILiveBrowserSessionLike,
1496
1752
  event: plugins.smartpuppeteer.TLiveBrowserEvent,
1497
1753
  ): void {
@@ -1530,19 +1786,27 @@ export class BrowserRuntime {
1530
1786
  return;
1531
1787
  }
1532
1788
  if (event.frame.data.byteLength > this.options.maxFrameBytes) {
1533
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1789
+ this.trackCleanup(this.acknowledgeFrameInBackground(
1790
+ slot,
1791
+ session,
1792
+ acknowledgement,
1793
+ true,
1794
+ ));
1534
1795
  this.pushToSubscription(subscription, {
1535
1796
  type: 'error',
1536
1797
  error: { code: 'FRAME_TOO_LARGE', fatal: true, tabId: event.frame.tabId },
1537
1798
  });
1538
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1539
1799
  return;
1540
1800
  }
1541
1801
  const timer = setTimeout(() => {
1542
1802
  if (subscription.outstanding?.acknowledgement.sequence !== acknowledgement.sequence) return;
1543
1803
  subscription.outstanding = undefined;
1544
- this.trackCleanup(this.acknowledgeFrameInBackground(slot, session, acknowledgement));
1545
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1804
+ this.trackCleanup(this.acknowledgeFrameInBackground(
1805
+ slot,
1806
+ session,
1807
+ acknowledgement,
1808
+ true,
1809
+ ));
1546
1810
  }, this.options.frameAcknowledgementTimeoutMs);
1547
1811
  timer.unref();
1548
1812
  subscription.outstanding = { acknowledgement, timer };
@@ -1554,7 +1818,7 @@ export class BrowserRuntime {
1554
1818
  }
1555
1819
  if (!subscription || subscription.closed) return;
1556
1820
  if (event.type === 'state') {
1557
- this.pushToSubscription(subscription, { type: 'state', state: this.projectState(event.state) });
1821
+ this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
1558
1822
  } else {
1559
1823
  this.pushToSubscription(subscription, {
1560
1824
  type: 'error',
@@ -1575,17 +1839,34 @@ export class BrowserRuntime {
1575
1839
  this.requireValidLease(subscription.lease);
1576
1840
  subscription.listener(event);
1577
1841
  } catch {
1842
+ const slot = subscription.lease.slot;
1843
+ const session = slot.session;
1844
+ const incarnationGeneration = slot.incarnationGeneration;
1578
1845
  if (event.type === 'frame' && subscription.outstanding) {
1579
1846
  const outstanding = subscription.outstanding;
1580
1847
  clearTimeout(outstanding.timer);
1581
1848
  subscription.outstanding = undefined;
1582
1849
  this.trackCleanup(this.acknowledgeFrameInBackground(
1583
- subscription.lease.slot,
1584
- subscription.lease.slot.session!,
1850
+ slot,
1851
+ session!,
1585
1852
  outstanding.acknowledgement,
1853
+ true,
1586
1854
  ));
1855
+ return;
1587
1856
  }
1588
- this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1857
+ if (!session) {
1858
+ this.trackCleanup(this.revokeCapabilityRecord(subscription.lease.capability));
1859
+ return;
1860
+ }
1861
+ this.trackCleanup((async () => {
1862
+ await this.options.beforeFrameFailureTermination?.();
1863
+ await this.handleFrameFailure(
1864
+ slot,
1865
+ session,
1866
+ incarnationGeneration,
1867
+ subscription.lease,
1868
+ );
1869
+ })());
1589
1870
  }
1590
1871
  }
1591
1872
 
@@ -1600,42 +1881,130 @@ export class BrowserRuntime {
1600
1881
  subscription.outstanding = undefined;
1601
1882
  const session = slot.session;
1602
1883
  if (session) {
1884
+ const incarnationGeneration = slot.incarnationGeneration;
1603
1885
  const operation = session.acknowledgeFrame(acknowledgement);
1604
1886
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
1605
1887
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
1606
1888
  if ((!result.settled || !result.value.accepted) && slot.session === session) {
1607
1889
  operation.catch(() => undefined);
1608
- await this.terminateSlotSession(slot);
1890
+ await this.options.beforeFrameFailureTermination?.();
1891
+ await this.handleFrameFailure(
1892
+ slot,
1893
+ session,
1894
+ incarnationGeneration,
1895
+ subscription.lease,
1896
+ );
1609
1897
  }
1610
1898
  }
1611
1899
  }
1612
1900
  }
1613
1901
 
1614
1902
  private async acknowledgeFrameInBackground(
1615
- slot: IProjectSlot,
1903
+ slot: IResourceSlot,
1616
1904
  session: ILiveBrowserSessionLike,
1617
1905
  acknowledgement: plugins.smartpuppeteer.ILiveBrowserFrameAcknowledgementRequest,
1906
+ failLease = false,
1618
1907
  ): Promise<void> {
1908
+ const failedLease = slot.session === session ? slot.lease : undefined;
1909
+ const incarnationGeneration = slot.incarnationGeneration;
1619
1910
  const operation = session.acknowledgeFrame(acknowledgement);
1620
1911
  const result = await waitBounded(operation, this.options.frameAcknowledgementTimeoutMs)
1621
1912
  .catch(() => ({ settled: true as const, value: { accepted: false } }));
1622
- if (result.settled && result.value.accepted) return;
1913
+ if (result.settled && result.value.accepted && !failLease) return;
1623
1914
  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);
1915
+ await this.options.beforeFrameFailureTermination?.();
1916
+ await this.handleFrameFailure(slot, session, incarnationGeneration, failedLease);
1917
+ }
1918
+
1919
+ private async handleFrameFailure(
1920
+ slot: IResourceSlot,
1921
+ session: ILiveBrowserSessionLike,
1922
+ incarnationGeneration: number,
1923
+ lease?: ILeaseRecord,
1924
+ ): Promise<void> {
1925
+ let transitionGeneration = 0;
1926
+ let ownsTransitionFence = false;
1927
+ const release = await slot.mutex.acquire();
1928
+ try {
1929
+ if (lease) this.invalidateCapabilityRecord(lease.capability);
1930
+ const borrowsLeaseReleaseFence = Boolean(
1931
+ lease?.releaseGeneration !== undefined
1932
+ && slot.fencingGeneration === lease.releaseGeneration,
1933
+ );
1934
+ if (
1935
+ slot.session !== session
1936
+ || slot.incarnationGeneration !== incarnationGeneration
1937
+ || (lease && slot.lease !== lease)
1938
+ || (slot.fencingGeneration !== undefined && !borrowsLeaseReleaseFence)
1939
+ || slot.attachmentRetryBinding
1940
+ ) {
1941
+ if (lease) this.trackFrameCapabilityRevocation(lease);
1942
+ return;
1943
+ }
1944
+ if (borrowsLeaseReleaseFence) {
1945
+ transitionGeneration = slot.fencingGeneration!;
1946
+ } else {
1947
+ transitionGeneration = ++slot.arbitrationGeneration;
1948
+ slot.fencingGeneration = transitionGeneration;
1949
+ ownsTransitionFence = true;
1950
+ }
1951
+ if (lease) {
1952
+ lease.released = true;
1953
+ lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
1954
+ }
1955
+ if (!lease || slot.operation?.lease === lease) {
1956
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
1957
+ }
1958
+ } finally {
1959
+ release();
1960
+ }
1961
+ if (lease) this.trackFrameCapabilityRevocation(lease);
1962
+ await this.options.afterFrameFailureFence?.();
1963
+ if (lease) await this.closeFrameSubscriptionIfLease(lease);
1964
+ const errors: unknown[] = [];
1965
+ await this.quiesceSlot(slot).catch((error) => errors.push(error));
1966
+ await this.terminateSlotSession(slot, session).catch((error) => errors.push(error));
1967
+ const finalRelease = await slot.mutex.acquire();
1968
+ try {
1969
+ if (
1970
+ slot.arbitrationGeneration === transitionGeneration
1971
+ && slot.fencingGeneration === transitionGeneration
1972
+ && slot.incarnationGeneration === incarnationGeneration
1973
+ && slot.session !== session
1974
+ ) {
1975
+ if (ownsTransitionFence && lease && slot.lease === lease) slot.lease = undefined;
1976
+ if (ownsTransitionFence && lease && lease.capability.lease === lease) {
1977
+ lease.capability.lease = undefined;
1978
+ }
1979
+ if (!lease || slot.operation?.lease === lease) slot.operation = undefined;
1980
+ if (ownsTransitionFence && errors.length === 0) slot.fencingGeneration = undefined;
1981
+ }
1982
+ } finally {
1983
+ finalRelease();
1984
+ }
1985
+ if (errors.length === 1) throw errors[0];
1986
+ if (errors.length > 1) {
1987
+ throw new AggregateError(errors, 'Frame acknowledgement cleanup is incomplete');
1988
+ }
1989
+ }
1990
+
1991
+ private trackFrameCapabilityRevocation(lease: ILeaseRecord): void {
1992
+ const operation = lease.releasePromise
1993
+ ? lease.releasePromise.catch(() => undefined).then(() => (
1994
+ this.revokeCapabilityRecord(lease.capability)
1995
+ ))
1996
+ : this.revokeCapabilityRecord(lease.capability);
1997
+ this.trackCleanup(operation);
1627
1998
  }
1628
1999
 
1629
2000
  private requireValidLease(lease: ILeaseRecord): void {
1630
2001
  if (lease.released || lease.controller.signal.aborted) {
1631
2002
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1632
2003
  }
1633
- const capability = this.resolveCapability(
1634
- lease.digest,
1635
- lease.peerId,
1636
- lease.role,
1637
- lease.capability.source,
1638
- );
2004
+ const capability = this.resolveCapability(lease.digest, this.describeCapabilityIdentity(
2005
+ lease.capability,
2006
+ ));
2007
+ this.assertCapabilityStillAttached(capability, lease.slot);
1639
2008
  if (
1640
2009
  capability !== lease.capability
1641
2010
  || capability.lease !== lease
@@ -1648,14 +2017,10 @@ export class BrowserRuntime {
1648
2017
 
1649
2018
  private resolveCapability(
1650
2019
  digest: plugins.Buffer,
1651
- peerId: string,
1652
- expectedRole?: TBrowserActorRole,
1653
- expectedSource?: TBrowserCapabilitySource,
1654
- expectedScopeId?: string,
1655
- expectedSessionId?: string,
1656
- ): ICapabilityRecord {
2020
+ expected: TBrowserCapabilityAuthorizationRequest,
2021
+ ): TCapabilityRecord {
1657
2022
  const record = this.capabilitiesByDigest.get(digest.toString('hex'));
1658
- if (!record || !digestsEqual(record.digest, digest) || record.peerId !== peerId) {
2023
+ if (!record || !digestsEqual(record.digest, digest)) {
1659
2024
  throw new BrowserRuntimeError('CAPABILITY_INVALID');
1660
2025
  }
1661
2026
  if (record.expiresAt <= Date.now()) {
@@ -1664,41 +2029,28 @@ export class BrowserRuntime {
1664
2029
  throw new BrowserRuntimeError('CAPABILITY_EXPIRED');
1665
2030
  }
1666
2031
  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
- ) {
2032
+ if (!this.capabilityIdentitiesEqual(record, expected)) {
1673
2033
  throw new BrowserRuntimeError('CAPABILITY_INVALID');
1674
2034
  }
1675
2035
  return record;
1676
2036
  }
1677
2037
 
1678
- private describeCapability(record: ICapabilityRecord): IBrowserCapabilityDescriptor {
2038
+ private describeCapability(record: TCapabilityRecord): TBrowserCapabilityDescriptor {
1679
2039
  return {
2040
+ ...this.describeCapabilityIdentity(record),
1680
2041
  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
2042
  expiresAt: record.expiresAt,
1689
2043
  };
1690
2044
  }
1691
2045
 
1692
2046
  private async revokeCapabilityRecord(
1693
- capability: ICapabilityRecord,
2047
+ capability: TCapabilityRecord,
1694
2048
  invokeDisconnect = true,
1695
2049
  ): Promise<void> {
1696
2050
  if (capability.revocationComplete) return;
1697
2051
  if (capability.revocationPromise) return capability.revocationPromise;
1698
2052
  capability.revocationInvokeDisconnect ??= invokeDisconnect;
1699
- if (capability.state === 'active') capability.state = 'revoked';
1700
- clearTimeout(capability.expiryTimer);
1701
- this.capabilitiesByDigest.delete(capability.digest.toString('hex'));
2053
+ this.invalidateCapabilityRecord(capability);
1702
2054
  const operation = (async () => {
1703
2055
  const errors: unknown[] = [];
1704
2056
  const lease = capability.lease;
@@ -1732,9 +2084,11 @@ export class BrowserRuntime {
1732
2084
  capability.disconnectComplete = true;
1733
2085
  }
1734
2086
  } else {
1735
- const framedPeer = [...this.framedPeers].find((peer) => peer.peerId === capability.peerId);
2087
+ const framedPeer = [...this.framedPeers].find((peer) => (
2088
+ peer.activeCapabilityId === capability.capabilityId
2089
+ ));
1736
2090
  if (framedPeer) {
1737
- await framedPeer.close().then(
2091
+ await framedPeer.closeFromRuntime().then(
1738
2092
  () => { capability.disconnectComplete = true; },
1739
2093
  (error) => errors.push(error),
1740
2094
  );
@@ -1746,17 +2100,6 @@ export class BrowserRuntime {
1746
2100
  if (errors.length > 0) throw new AggregateError(errors, 'Capability cleanup is incomplete');
1747
2101
  capability.revocationComplete = true;
1748
2102
  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
2103
  })();
1761
2104
  capability.revocationPromise = operation;
1762
2105
  try {
@@ -1770,6 +2113,12 @@ export class BrowserRuntime {
1770
2113
  }
1771
2114
  }
1772
2115
 
2116
+ private invalidateCapabilityRecord(capability: TCapabilityRecord): void {
2117
+ if (capability.state === 'active') capability.state = 'revoked';
2118
+ clearTimeout(capability.expiryTimer);
2119
+ this.capabilitiesByDigest.delete(capability.digest.toString('hex'));
2120
+ }
2121
+
1773
2122
  private async releaseLeaseRecord(lease: ILeaseRecord): Promise<void> {
1774
2123
  if (lease.releasePromise) return lease.releasePromise;
1775
2124
  const operation = this.releaseLeaseRecordInternal(lease);
@@ -1785,10 +2134,14 @@ export class BrowserRuntime {
1785
2134
  if (lease.released && lease.slot.lease !== lease) return;
1786
2135
  const slot = lease.slot;
1787
2136
  const release = await slot.mutex.acquire();
1788
- const generation = lease.releaseGeneration ?? ++slot.generation;
2137
+ const existingFence = slot.fencingGeneration;
2138
+ const generation = lease.releaseGeneration
2139
+ ?? existingFence
2140
+ ?? ++slot.arbitrationGeneration;
1789
2141
  lease.releaseGeneration = generation;
2142
+ lease.releaseOwnsFence ??= existingFence === undefined;
1790
2143
  try {
1791
- if (slot.fencingGeneration !== undefined && slot.fencingGeneration !== generation) {
2144
+ if (existingFence !== undefined) {
1792
2145
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
1793
2146
  } else {
1794
2147
  slot.fencingGeneration = generation;
@@ -1809,11 +2162,15 @@ export class BrowserRuntime {
1809
2162
  ) await this.terminateSlotSession(slot);
1810
2163
  const finalRelease = await slot.mutex.acquire();
1811
2164
  try {
2165
+ if (lease.capability.state === 'active') this.notifyFramedLeaseEnded(lease);
1812
2166
  if (slot.lease === lease) slot.lease = undefined;
1813
2167
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
1814
- if (slot.fencingGeneration === generation) slot.fencingGeneration = undefined;
2168
+ if (lease.releaseOwnsFence && slot.fencingGeneration === generation) {
2169
+ slot.fencingGeneration = undefined;
2170
+ }
1815
2171
  lease.releaseGeneration = undefined;
1816
- this.scheduleIdleRetirement(slot);
2172
+ lease.releaseOwnsFence = undefined;
2173
+ this.scheduleIdleTermination(slot);
1817
2174
  } finally {
1818
2175
  finalRelease();
1819
2176
  }
@@ -1824,7 +2181,7 @@ export class BrowserRuntime {
1824
2181
  if (subscription?.lease === lease) await this.closeFrameSubscription(subscription);
1825
2182
  }
1826
2183
 
1827
- private async quiesceSlot(slot: IProjectSlot): Promise<void> {
2184
+ private async quiesceSlot(slot: IResourceSlot): Promise<void> {
1828
2185
  const operation = slot.operation;
1829
2186
  if (!operation) return;
1830
2187
  operation.controller.abort(new BrowserRuntimeError('ABORTED'));
@@ -1840,8 +2197,16 @@ export class BrowserRuntime {
1840
2197
  await this.revokeCapabilityRecord(operation.lease.capability);
1841
2198
  }
1842
2199
 
1843
- private async terminateSlotSession(slot: IProjectSlot): Promise<void> {
2200
+ private async terminateSlotSession(
2201
+ slot: IResourceSlot,
2202
+ expectedSession?: ILiveBrowserSessionLike,
2203
+ ): Promise<void> {
2204
+ if (slot.idleTimer) {
2205
+ clearTimeout(slot.idleTimer);
2206
+ slot.idleTimer = undefined;
2207
+ }
1844
2208
  const session = slot.session;
2209
+ if (expectedSession && session !== expectedSession) return;
1845
2210
  if (session) {
1846
2211
  let termination: plugins.smartpuppeteer.ILiveBrowserTerminationResult;
1847
2212
  try {
@@ -1881,13 +2246,24 @@ export class BrowserRuntime {
1881
2246
  if (slot.proxy || slot.profileDirectory) throw new BrowserRuntimeError('FENCED');
1882
2247
  }
1883
2248
 
1884
- private scheduleIdleRetirement(slot: IProjectSlot): void {
2249
+ private scheduleIdleTermination(slot: IResourceSlot): void {
2250
+ if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
1885
2251
  if (slot.idleTimer) clearTimeout(slot.idleTimer);
1886
- const generation = slot.generation;
2252
+ const arbitrationGeneration = slot.arbitrationGeneration;
2253
+ const incarnationGeneration = slot.incarnationGeneration;
2254
+ const scheduledSession = slot.session;
1887
2255
  slot.idleTimer = setTimeout(() => {
1888
- if (slot.generation !== generation || slot.lease) return;
1889
- this.trackCleanup(this.retireSlot(slot.projectId, false));
1890
- }, this.options.idleRetirementMs);
2256
+ slot.idleTimer = undefined;
2257
+ this.trackCleanup(this.enqueueResourceLifecycle(slot, async () => {
2258
+ await this.options.beforeIdleTermination?.();
2259
+ await this.terminateRegisteredResourceInternal(slot, false, {
2260
+ arbitrationGeneration,
2261
+ incarnationGeneration,
2262
+ session: scheduledSession,
2263
+ requireIdle: true,
2264
+ });
2265
+ }));
2266
+ }, this.options.idleTerminationMs);
1891
2267
  slot.idleTimer.unref();
1892
2268
  }
1893
2269
 
@@ -1903,14 +2279,33 @@ export class BrowserRuntime {
1903
2279
  }
1904
2280
  }
1905
2281
 
1906
- private async retireSlot(projectId: string, force: boolean): Promise<void> {
1907
- const slot = this.slots.get(projectId);
1908
- if (!slot) return;
2282
+ private async terminateRegisteredResourceInternal(
2283
+ slot: IResourceSlot,
2284
+ stopping: boolean,
2285
+ expected?: {
2286
+ arbitrationGeneration?: number;
2287
+ incarnationGeneration?: number;
2288
+ session?: ILiveBrowserSessionLike;
2289
+ requireIdle?: boolean;
2290
+ },
2291
+ ): Promise<void> {
1909
2292
  const release = await slot.mutex.acquire();
1910
- const generation = ++slot.generation;
2293
+ let generation = 0;
2294
+ let attachmentFenceOwnsTransition = false;
1911
2295
  try {
1912
- if (!force && (slot.lease || slot.operation)) return;
1913
- slot.fencingGeneration = generation;
2296
+ if (
2297
+ (expected?.arbitrationGeneration !== undefined
2298
+ && slot.arbitrationGeneration !== expected.arbitrationGeneration)
2299
+ || (expected?.incarnationGeneration !== undefined
2300
+ && slot.incarnationGeneration !== expected.incarnationGeneration)
2301
+ || (expected?.session !== undefined && slot.session !== expected.session)
2302
+ || (expected?.requireIdle && slot.lease)
2303
+ ) return;
2304
+ attachmentFenceOwnsTransition = slot.attachmentRetryBinding !== undefined;
2305
+ if (!attachmentFenceOwnsTransition) {
2306
+ generation = ++slot.arbitrationGeneration;
2307
+ slot.fencingGeneration = generation;
2308
+ }
1914
2309
  if (slot.idleTimer) {
1915
2310
  clearTimeout(slot.idleTimer);
1916
2311
  slot.idleTimer = undefined;
@@ -1918,18 +2313,27 @@ export class BrowserRuntime {
1918
2313
  if (slot.lease) {
1919
2314
  slot.lease.released = true;
1920
2315
  slot.lease.controller.abort(new BrowserRuntimeError('ABORTED'));
2316
+ if (!stopping && !attachmentFenceOwnsTransition) {
2317
+ this.notifyFramedLeaseEnded(slot.lease);
2318
+ }
1921
2319
  }
1922
2320
  slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
1923
2321
  } finally {
1924
2322
  release();
1925
2323
  }
2324
+ if (attachmentFenceOwnsTransition) {
2325
+ await this.quiesceSlot(slot);
2326
+ if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
2327
+ await this.terminateSlotSession(slot, expected?.session);
2328
+ return;
2329
+ }
2330
+ if (generation === 0) return;
1926
2331
  await this.quiesceSlot(slot);
1927
2332
  if (slot.lease) await this.closeFrameSubscriptionIfLease(slot.lease);
1928
- await this.terminateSlotSession(slot);
1929
- await this.artifactStore.purgeProject(projectId);
2333
+ await this.terminateSlotSession(slot, expected?.session);
1930
2334
  const finalRelease = await slot.mutex.acquire();
1931
2335
  try {
1932
- if (slot.generation !== generation || slot.fencingGeneration !== generation) {
2336
+ if (slot.arbitrationGeneration !== generation || slot.fencingGeneration !== generation) {
1933
2337
  throw new BrowserRuntimeError('BUSY');
1934
2338
  }
1935
2339
  const lease = slot.lease;
@@ -1939,14 +2343,48 @@ export class BrowserRuntime {
1939
2343
  slot.lease = undefined;
1940
2344
  slot.operation = undefined;
1941
2345
  slot.fencingGeneration = undefined;
1942
- this.slots.delete(projectId);
1943
2346
  } finally {
1944
2347
  finalRelease();
1945
2348
  }
1946
2349
  }
1947
2350
 
1948
- private assertSlotAvailable(slot: IProjectSlot): void {
2351
+ private async retireRegisteredResourceInternal(slot: IResourceSlot): Promise<void> {
2352
+ const key = this.resourceKey(slot.projectId, slot.browserResourceId);
2353
+ if (this.slots.get(key) !== slot) return;
2354
+ const peerResults = await Promise.allSettled(
2355
+ this.framedPeersForResource(slot).map((peer) => peer.close()),
2356
+ );
2357
+ const capabilities = this.capabilitiesForResource(slot);
2358
+ const capabilityResults = await Promise.allSettled(
2359
+ capabilities.map((record) => this.revokeCapabilityRecord(record)),
2360
+ );
2361
+ const errors = [...peerResults, ...capabilityResults]
2362
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2363
+ .map((result) => result.reason);
2364
+ await this.terminateRegisteredResourceInternal(slot, true).catch((error) => errors.push(error));
2365
+ if (errors.length === 0) {
2366
+ await this.artifactStore.purgeResource(slot.projectId, slot.browserResourceId)
2367
+ .catch((error) => errors.push(error));
2368
+ }
2369
+ if (errors.length > 0) {
2370
+ slot.permanentlyFenced = true;
2371
+ throw new AggregateError(errors, 'Resource retirement is incomplete');
2372
+ }
2373
+ this.slots.delete(key);
2374
+ }
2375
+
2376
+ private framedPeersForResource(slot: IResourceSlot): BrowserRuntimeFramedServerPeer[] {
2377
+ return [...this.framedPeers].filter((peer) => (
2378
+ peer.projectId === slot.projectId
2379
+ && peer.browserResourceId === slot.browserResourceId
2380
+ ));
2381
+ }
2382
+
2383
+ private assertSlotAvailable(slot: IResourceSlot): void {
2384
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
1949
2385
  if (slot.permanentlyFenced) throw new BrowserRuntimeError('FENCED');
2386
+ if (slot.terminationFenceGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
2387
+ if (slot.attachmentFenceCount > 0) throw new BrowserRuntimeError('BUSY');
1950
2388
  if (slot.fencingGeneration !== undefined) throw new BrowserRuntimeError('BUSY');
1951
2389
  }
1952
2390
 
@@ -1958,6 +2396,416 @@ export class BrowserRuntime {
1958
2396
  if (lease.role !== 'human') throw new BrowserRuntimeError('CAPABILITY_INVALID');
1959
2397
  }
1960
2398
 
2399
+ private enqueueResourceLifecycle<T>(
2400
+ slot: IResourceSlot,
2401
+ operation: () => Promise<T>,
2402
+ ): Promise<T> {
2403
+ slot.lifecycleOperationCount += 1;
2404
+ const queued = slot.lifecycleTail.then(operation, operation);
2405
+ const result = queued.finally(() => {
2406
+ slot.lifecycleOperationCount = Math.max(0, slot.lifecycleOperationCount - 1);
2407
+ });
2408
+ slot.lifecycleTail = result.then(() => undefined, () => undefined);
2409
+ return result;
2410
+ }
2411
+
2412
+ private notifyFramedLeaseEnded(lease: ILeaseRecord): void {
2413
+ if (lease.capability.source !== 'flex') return;
2414
+ const peer = [...this.framedPeers].find((candidate) => (
2415
+ candidate.activeCapabilityId === lease.capability.capabilityId
2416
+ && candidate.channelId === lease.capability.channelId
2417
+ ));
2418
+ peer?.releaseLeaseFromRuntime(lease.capability.capabilityId, lease.leaseId);
2419
+ }
2420
+
2421
+ private async applyAttachmentBindingInternal(
2422
+ slot: IResourceSlot,
2423
+ binding: IBrowserAttachmentBinding,
2424
+ ): Promise<IBrowserResourceRegistration> {
2425
+ if (slot.retirementPending) throw new BrowserRuntimeError('RESOURCE_RETIRED');
2426
+ const current = slot.attachmentBinding;
2427
+ if (binding.attachmentRevision < current.attachmentRevision) {
2428
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2429
+ }
2430
+ if (
2431
+ binding.attachmentRevision === current.attachmentRevision
2432
+ && !slot.attachmentRetryBinding
2433
+ ) {
2434
+ if (!this.bindingsEqual(binding, current)) {
2435
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2436
+ }
2437
+ return this.describeResource(slot);
2438
+ }
2439
+ if (
2440
+ slot.attachmentRetryBinding
2441
+ && !this.bindingsEqual(binding, slot.attachmentRetryBinding)
2442
+ ) throw new BrowserRuntimeError('BUSY');
2443
+ const release = await slot.mutex.acquire();
2444
+ let fenceGeneration = 0;
2445
+ try {
2446
+ fenceGeneration = ++slot.arbitrationGeneration;
2447
+ slot.fencingGeneration = fenceGeneration;
2448
+ slot.attachmentBinding = binding;
2449
+ slot.attachmentRetryBinding = binding;
2450
+ slot.lease?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2451
+ slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
2452
+ } finally {
2453
+ release();
2454
+ }
2455
+ const peerResults = await Promise.allSettled(
2456
+ this.framedPeersForResource(slot).map((peer) => peer.close()),
2457
+ );
2458
+ const capabilities = this.capabilitiesForResource(slot);
2459
+ const capabilityResults = await Promise.allSettled(
2460
+ capabilities.map((record) => this.revokeCapabilityRecord(record)),
2461
+ );
2462
+ const errors = [...peerResults, ...capabilityResults]
2463
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
2464
+ .map((result) => result.reason);
2465
+ await this.quiesceSlot(slot).catch((error) => errors.push(error));
2466
+ const finalRelease = await slot.mutex.acquire();
2467
+ try {
2468
+ if (slot.fencingGeneration === fenceGeneration && errors.length === 0) {
2469
+ slot.fencingGeneration = undefined;
2470
+ slot.attachmentRetryBinding = undefined;
2471
+ }
2472
+ } finally {
2473
+ finalRelease();
2474
+ }
2475
+ if (errors.length > 0) {
2476
+ throw new AggregateError(errors, 'Attachment fencing is incomplete');
2477
+ }
2478
+ return this.describeResource(slot);
2479
+ }
2480
+
2481
+ private attachmentBindingKey(binding: IBrowserAttachmentBinding): string {
2482
+ const session = binding.sessionId
2483
+ ? `${binding.sessionId.harnessId}:${binding.sessionId.nativeId}`
2484
+ : 'detached';
2485
+ return `${binding.attachmentRevision}:${binding.attachmentAuthorityId}:${session}`;
2486
+ }
2487
+
2488
+ private tombstoneIndex(value: string, seed: number): number {
2489
+ const digest = plugins.crypto
2490
+ .createHash('sha256')
2491
+ .update(String(seed), 'utf8')
2492
+ .update('\0', 'utf8')
2493
+ .update(value, 'utf8')
2494
+ .digest();
2495
+ return digest.readUInt32BE(0) % (this.retiredResourceIds.length * 8);
2496
+ }
2497
+
2498
+ private addRetiredTombstone(tombstones: Uint8Array, value: string): void {
2499
+ for (let seed = 0; seed < 4; seed += 1) {
2500
+ const index = this.tombstoneIndex(value, seed);
2501
+ tombstones[index >>> 3] |= 1 << (index & 7);
2502
+ }
2503
+ }
2504
+
2505
+ private hasRetiredTombstone(tombstones: Uint8Array, value: string): boolean {
2506
+ for (let seed = 0; seed < 4; seed += 1) {
2507
+ const index = this.tombstoneIndex(value, seed);
2508
+ if ((tombstones[index >>> 3]! & (1 << (index & 7))) === 0) return false;
2509
+ }
2510
+ return true;
2511
+ }
2512
+
2513
+ private resourceKey(projectId: string, browserResourceId: string): string {
2514
+ return `${projectId.length}:${projectId}${browserResourceId}`;
2515
+ }
2516
+
2517
+ private validateIdentifier(value: unknown, name: string): string {
2518
+ return validateBoundedString(value, name, 1, identifierMaximum);
2519
+ }
2520
+
2521
+ private validateResourceId(value: unknown): string {
2522
+ return validateBoundedString(value, 'browserResourceId', 16, identifierMaximum);
2523
+ }
2524
+
2525
+ private validateQualifiedSessionId(value: unknown): TQualifiedBrowserSessionId {
2526
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2527
+ throw new BrowserRuntimeError('INVALID_INPUT');
2528
+ }
2529
+ const record = value as Record<string, unknown>;
2530
+ if (Object.keys(record).some((key) => key !== 'harnessId' && key !== 'nativeId')) {
2531
+ throw new BrowserRuntimeError('INVALID_INPUT');
2532
+ }
2533
+ if (record.harnessId !== 'opencode' && record.harnessId !== 'flex') {
2534
+ throw new BrowserRuntimeError('INVALID_INPUT');
2535
+ }
2536
+ return {
2537
+ harnessId: record.harnessId,
2538
+ nativeId: this.validateIdentifier(record.nativeId, 'sessionId.nativeId'),
2539
+ };
2540
+ }
2541
+
2542
+ private validateAttachmentBinding(value: unknown): IBrowserAttachmentBinding {
2543
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2544
+ throw new BrowserRuntimeError('INVALID_INPUT');
2545
+ }
2546
+ const record = value as Record<string, unknown>;
2547
+ if (Object.keys(record).some((key) => (
2548
+ key !== 'attachmentAuthorityId'
2549
+ && key !== 'attachmentRevision'
2550
+ && key !== 'sessionId'
2551
+ ))) throw new BrowserRuntimeError('INVALID_INPUT');
2552
+ const attachmentRevision = validateInteger(
2553
+ record.attachmentRevision,
2554
+ 'attachmentRevision',
2555
+ 0,
2556
+ Number.MAX_SAFE_INTEGER,
2557
+ );
2558
+ const sessionId = record.sessionId === null
2559
+ ? null
2560
+ : this.validateQualifiedSessionId(record.sessionId);
2561
+ if (attachmentRevision === 0 && sessionId !== null) {
2562
+ throw new BrowserRuntimeError('INVALID_INPUT');
2563
+ }
2564
+ return {
2565
+ attachmentAuthorityId: this.validateIdentifier(
2566
+ record.attachmentAuthorityId,
2567
+ 'attachmentAuthorityId',
2568
+ ),
2569
+ attachmentRevision,
2570
+ sessionId,
2571
+ };
2572
+ }
2573
+
2574
+ private bindingsEqual(
2575
+ left: IBrowserAttachmentBinding,
2576
+ right: IBrowserAttachmentBinding,
2577
+ ): boolean {
2578
+ return left.attachmentAuthorityId === right.attachmentAuthorityId
2579
+ && left.attachmentRevision === right.attachmentRevision
2580
+ && this.sessionsEqual(left.sessionId, right.sessionId);
2581
+ }
2582
+
2583
+ private sessionsEqual(
2584
+ left: TQualifiedBrowserSessionId | null | undefined,
2585
+ right: TQualifiedBrowserSessionId | null | undefined,
2586
+ ): boolean {
2587
+ if (left == null || right == null) return left == null && right == null;
2588
+ return left.harnessId === right.harnessId && left.nativeId === right.nativeId;
2589
+ }
2590
+
2591
+ private resolveResourceRequest(
2592
+ request: { projectId: string; browserResourceId: string },
2593
+ ): IResourceSlot {
2594
+ if (!request || typeof request !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
2595
+ return this.getRegisteredSlot(request.projectId, request.browserResourceId);
2596
+ }
2597
+
2598
+ private getRegisteredSlot(projectIdArg: unknown, browserResourceIdArg: unknown): IResourceSlot {
2599
+ const projectId = this.validateIdentifier(projectIdArg, 'projectId');
2600
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
2601
+ const key = this.resourceKey(projectId, browserResourceId);
2602
+ const slot = this.slots.get(key);
2603
+ if (!slot) {
2604
+ throw new BrowserRuntimeError(
2605
+ this.hasRetiredTombstone(this.retiredResourceKeys, key)
2606
+ ? 'RESOURCE_RETIRED'
2607
+ : 'RESOURCE_NOT_FOUND',
2608
+ );
2609
+ }
2610
+ return slot;
2611
+ }
2612
+
2613
+ private describeResource(slot: IResourceSlot): IBrowserResourceRegistration {
2614
+ return {
2615
+ projectId: slot.projectId,
2616
+ browserResourceId: slot.browserResourceId,
2617
+ registeredAt: slot.registeredAt,
2618
+ attachmentBinding: {
2619
+ ...slot.attachmentBinding,
2620
+ sessionId: slot.attachmentBinding.sessionId
2621
+ ? { ...slot.attachmentBinding.sessionId }
2622
+ : null,
2623
+ },
2624
+ incarnationStatus: slot.permanentlyFenced || slot.attachmentRetryBinding
2625
+ ? 'fenced'
2626
+ : slot.session
2627
+ ? 'running'
2628
+ : 'absent',
2629
+ incarnationGeneration: slot.incarnationGeneration,
2630
+ retirementPending: slot.retirementPending,
2631
+ };
2632
+ }
2633
+
2634
+ private assertCapabilityBinding(
2635
+ slot: IResourceSlot,
2636
+ expected: Pick<
2637
+ TIssueBrowserCapabilityRequest,
2638
+ 'attachmentAuthorityId' | 'attachmentRevision' | 'role' | 'sessionId'
2639
+ >,
2640
+ ): void {
2641
+ const binding = slot.attachmentBinding;
2642
+ if (
2643
+ binding.attachmentAuthorityId !== expected.attachmentAuthorityId
2644
+ || binding.attachmentRevision !== expected.attachmentRevision
2645
+ ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2646
+ if (expected.role === 'agent') {
2647
+ if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
2648
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2649
+ }
2650
+ } else if (expected.sessionId !== undefined) {
2651
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
2652
+ }
2653
+ }
2654
+
2655
+ private assertCapabilityStillAttached(
2656
+ capability: TCapabilityRecord,
2657
+ slot: IResourceSlot,
2658
+ ): void {
2659
+ if (
2660
+ capability.projectId !== slot.projectId
2661
+ || capability.browserResourceId !== slot.browserResourceId
2662
+ ) throw new BrowserRuntimeError('CAPABILITY_INVALID');
2663
+ try {
2664
+ this.assertCapabilityBinding(slot, capability);
2665
+ } catch {
2666
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
2667
+ }
2668
+ }
2669
+
2670
+ private validateExpectedCapabilityBinding(
2671
+ value: TBrowserCapabilityAuthorizationRequest,
2672
+ ): TBrowserCapabilityAuthorizationRequest {
2673
+ if (!value || typeof value !== 'object') throw new BrowserRuntimeError('INVALID_INPUT');
2674
+ const role = this.validateRole(value.role);
2675
+ const source = this.validateSource(value.source);
2676
+ const sessionId = value.sessionId === undefined
2677
+ ? undefined
2678
+ : this.validateQualifiedSessionId(value.sessionId);
2679
+ const scopeId = value.scopeId === undefined
2680
+ ? undefined
2681
+ : this.validateIdentifier(value.scopeId, 'scopeId');
2682
+ const channelId = value.channelId === undefined
2683
+ ? undefined
2684
+ : this.validateIdentifier(value.channelId, 'channelId');
2685
+ if (
2686
+ (role === 'agent' && !sessionId)
2687
+ || (role === 'human' && sessionId !== undefined)
2688
+ || (source === 'flex' && (!scopeId || !channelId || sessionId?.harnessId !== 'flex'))
2689
+ || (source === 'mcp' && (scopeId !== undefined || channelId !== undefined
2690
+ || sessionId?.harnessId !== 'opencode'))
2691
+ || (source === 'human' && (scopeId !== undefined || channelId !== undefined))
2692
+ ) throw new BrowserRuntimeError('INVALID_INPUT');
2693
+ const base = {
2694
+ projectId: this.validateIdentifier(value.projectId, 'projectId'),
2695
+ browserResourceId: this.validateResourceId(value.browserResourceId),
2696
+ attachmentAuthorityId: this.validateIdentifier(
2697
+ value.attachmentAuthorityId,
2698
+ 'attachmentAuthorityId',
2699
+ ),
2700
+ attachmentRevision: validateInteger(
2701
+ value.attachmentRevision,
2702
+ 'attachmentRevision',
2703
+ 0,
2704
+ Number.MAX_SAFE_INTEGER,
2705
+ ),
2706
+ actorId: this.validateIdentifier(value.actorId, 'actorId'),
2707
+ peerId: this.validateIdentifier(value.peerId, 'peerId'),
2708
+ };
2709
+ if (role === 'human' && source === 'human') {
2710
+ return { ...base, role, source };
2711
+ }
2712
+ if (role === 'agent' && source === 'flex' && sessionId?.harnessId === 'flex') {
2713
+ return { ...base, role, source, sessionId, scopeId: scopeId!, channelId: channelId! };
2714
+ }
2715
+ if (role === 'agent' && source === 'mcp' && sessionId?.harnessId === 'opencode') {
2716
+ return { ...base, role, source, sessionId };
2717
+ }
2718
+ throw new BrowserRuntimeError('INVALID_INPUT');
2719
+ }
2720
+
2721
+ private describeCapabilityIdentity(
2722
+ capability: TBrowserCapabilityDescriptor,
2723
+ ): TBrowserCapabilityAuthorizationRequest {
2724
+ const base = {
2725
+ projectId: capability.projectId,
2726
+ browserResourceId: capability.browserResourceId,
2727
+ attachmentAuthorityId: capability.attachmentAuthorityId,
2728
+ attachmentRevision: capability.attachmentRevision,
2729
+ actorId: capability.actorId,
2730
+ peerId: capability.peerId,
2731
+ };
2732
+ if (capability.role === 'human' && capability.source === 'human') {
2733
+ return { ...base, role: capability.role, source: capability.source };
2734
+ }
2735
+ if (
2736
+ capability.role === 'agent'
2737
+ && capability.source === 'flex'
2738
+ && capability.sessionId?.harnessId === 'flex'
2739
+ ) {
2740
+ return {
2741
+ ...base,
2742
+ role: capability.role,
2743
+ source: capability.source,
2744
+ sessionId: capability.sessionId,
2745
+ scopeId: capability.scopeId!,
2746
+ channelId: capability.channelId!,
2747
+ };
2748
+ }
2749
+ if (
2750
+ capability.role === 'agent'
2751
+ && capability.source === 'mcp'
2752
+ && capability.sessionId?.harnessId === 'opencode'
2753
+ ) {
2754
+ return {
2755
+ ...base,
2756
+ role: capability.role,
2757
+ source: capability.source,
2758
+ sessionId: capability.sessionId,
2759
+ };
2760
+ }
2761
+ throw new BrowserRuntimeError('CAPABILITY_INVALID');
2762
+ }
2763
+
2764
+ private capabilityIdentitiesEqual(
2765
+ left: TBrowserCapabilityAuthorizationRequest,
2766
+ right: TBrowserCapabilityAuthorizationRequest,
2767
+ ): boolean {
2768
+ return left.projectId === right.projectId
2769
+ && left.browserResourceId === right.browserResourceId
2770
+ && left.attachmentAuthorityId === right.attachmentAuthorityId
2771
+ && left.attachmentRevision === right.attachmentRevision
2772
+ && left.actorId === right.actorId
2773
+ && left.role === right.role
2774
+ && left.peerId === right.peerId
2775
+ && left.source === right.source
2776
+ && left.scopeId === right.scopeId
2777
+ && left.channelId === right.channelId
2778
+ && this.sessionsEqual(left.sessionId, right.sessionId);
2779
+ }
2780
+
2781
+ private capabilitiesForResource(slot: IResourceSlot): TCapabilityRecord[] {
2782
+ return [...this.capabilitiesById.values()].filter((record) => (
2783
+ record.projectId === slot.projectId
2784
+ && record.browserResourceId === slot.browserResourceId
2785
+ ));
2786
+ }
2787
+
2788
+ private reserveLaunch(slot: IResourceSlot): void {
2789
+ const running = [...this.slots.values()].filter((candidate) => Boolean(candidate.session)).length;
2790
+ const runningForProject = [...this.slots.values()].filter((candidate) => (
2791
+ candidate.projectId === slot.projectId && candidate.session
2792
+ )).length;
2793
+ const pendingForProject = this.pendingLaunchesByProject.get(slot.projectId) ?? 0;
2794
+ if (
2795
+ running + this.pendingLaunches >= this.options.maxRunningResources
2796
+ || runningForProject + pendingForProject >= this.options.maxRunningResourcesPerProject
2797
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
2798
+ this.pendingLaunches += 1;
2799
+ this.pendingLaunchesByProject.set(slot.projectId, pendingForProject + 1);
2800
+ }
2801
+
2802
+ private releaseLaunchReservation(slot: IResourceSlot): void {
2803
+ this.pendingLaunches = Math.max(0, this.pendingLaunches - 1);
2804
+ const remaining = (this.pendingLaunchesByProject.get(slot.projectId) ?? 1) - 1;
2805
+ if (remaining > 0) this.pendingLaunchesByProject.set(slot.projectId, remaining);
2806
+ else this.pendingLaunchesByProject.delete(slot.projectId);
2807
+ }
2808
+
1961
2809
  private validateRole(value: unknown): TBrowserActorRole {
1962
2810
  if (value !== 'human' && value !== 'agent') throw new BrowserRuntimeError('INVALID_INPUT');
1963
2811
  return value;
@@ -1977,7 +2825,7 @@ export class BrowserRuntime {
1977
2825
  this.cleanupOperations.add(observed);
1978
2826
  }
1979
2827
 
1980
- private async emitAudit(event: IBrowserRuntimeAuditEvent): Promise<void> {
2828
+ private async emitAudit(event: TBrowserRuntimeAuditEvent): Promise<void> {
1981
2829
  if (!this.options.audit) return;
1982
2830
  const controller = new AbortController();
1983
2831
  const operation = Promise.resolve().then(() => this.options.audit!(event, controller.signal));
@@ -2002,6 +2850,10 @@ export class BrowserRuntimeLease {
2002
2850
  return this.record.leaseId;
2003
2851
  }
2004
2852
 
2853
+ public get capabilityId(): string {
2854
+ return this.record.capability.capabilityId;
2855
+ }
2856
+
2005
2857
  public get role(): TBrowserActorRole {
2006
2858
  return this.record.role;
2007
2859
  }
@@ -2010,6 +2862,18 @@ export class BrowserRuntimeLease {
2010
2862
  return this.record.capability.projectId;
2011
2863
  }
2012
2864
 
2865
+ public get browserResourceId(): string {
2866
+ return this.record.capability.browserResourceId;
2867
+ }
2868
+
2869
+ public get attachmentAuthorityId(): string {
2870
+ return this.record.capability.attachmentAuthorityId;
2871
+ }
2872
+
2873
+ public get attachmentRevision(): number {
2874
+ return this.record.capability.attachmentRevision;
2875
+ }
2876
+
2013
2877
  public getState(): IBrowserRuntimeState {
2014
2878
  return this.runtime.getLeaseState(this.record);
2015
2879
  }