@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.
@@ -2,8 +2,15 @@ import * as plugins from './plugins.js';
2
2
  import { validateAgentAction, validateAgentActionResult } from './actions.js';
3
3
  import type { BrowserRuntime, BrowserRuntimeLease } from './classes.runtime.js';
4
4
  import { BrowserRuntimeError, type TBrowserRuntimeErrorCode } from './errors.js';
5
+ import {
6
+ browserRuntimeTesting,
7
+ type IBrowserRuntimeFramedClientTestingOptions,
8
+ } from './internal.testing.js';
5
9
  import type {
6
10
  IAttachTrustedFramedPeerOptions,
11
+ TBrowserCapabilityAuthorizationRequest,
12
+ IBrowserFlexCapabilityBinding,
13
+ IBrowserFlexSessionId,
7
14
  IBrowserRuntimeFramedClientOptions,
8
15
  IBrowserRuntimeOperationOptions,
9
16
  TBrowserAgentAction,
@@ -190,12 +197,24 @@ class JsonFramedChannel {
190
197
  }
191
198
 
192
199
  export class BrowserRuntimeFramedServerPeer {
200
+ private readonly trustedProjectId: string;
201
+ private readonly trustedBrowserResourceId: string;
202
+ private readonly trustedAttachmentAuthorityId: string;
203
+ private readonly trustedAttachmentRevision: number;
204
+ private readonly trustedActorId: string;
193
205
  private readonly trustedPeerId: string;
206
+ private readonly trustedRole: 'agent';
207
+ private readonly trustedSource: 'flex';
194
208
  private readonly trustedScopeId: string;
195
- private readonly trustedSessionId: string;
209
+ private readonly trustedChannelId: string;
210
+ private readonly trustedSessionId: IBrowserFlexSessionId;
196
211
  private readonly channel: JsonFramedChannel;
197
212
  private readonly activeRequests = new Map<string, AbortController>();
198
213
  private lease?: BrowserRuntimeLease;
214
+ private capabilityId?: string;
215
+ private pendingCapabilityId?: string;
216
+ private pendingReleasedLeaseId?: string;
217
+ private runtimeReleasedLease = false;
199
218
  private started = false;
200
219
  private closePromise?: Promise<void>;
201
220
  private closing = false;
@@ -206,9 +225,39 @@ export class BrowserRuntimeFramedServerPeer {
206
225
  options: IAttachTrustedFramedPeerOptions,
207
226
  private readonly onClosed: () => void = () => undefined,
208
227
  ) {
209
- this.trustedPeerId = validateBoundedString(options.peerId, 'peerId', 1, 128);
210
- this.trustedScopeId = validateBoundedString(options.scopeId, 'scopeId', 1, 128);
211
- this.trustedSessionId = validateBoundedString(options.sessionId, 'sessionId', 1, 128);
228
+ this.trustedProjectId = validateBoundedString(options.projectId, 'projectId', 1, 256);
229
+ this.trustedBrowserResourceId = validateBoundedString(
230
+ options.browserResourceId,
231
+ 'browserResourceId',
232
+ 16,
233
+ 256,
234
+ );
235
+ this.trustedAttachmentAuthorityId = validateBoundedString(
236
+ options.attachmentAuthorityId,
237
+ 'attachmentAuthorityId',
238
+ 1,
239
+ 256,
240
+ );
241
+ this.trustedAttachmentRevision = validateInteger(
242
+ options.attachmentRevision,
243
+ 'attachmentRevision',
244
+ 0,
245
+ Number.MAX_SAFE_INTEGER,
246
+ );
247
+ this.trustedActorId = validateBoundedString(options.actorId, 'actorId', 1, 256);
248
+ this.trustedPeerId = validateBoundedString(options.peerId, 'peerId', 1, 256);
249
+ if (options.role !== 'agent' || options.source !== 'flex') {
250
+ throw new BrowserRuntimeError('INVALID_INPUT');
251
+ }
252
+ this.trustedRole = options.role;
253
+ this.trustedSource = options.source;
254
+ this.trustedScopeId = validateBoundedString(options.scopeId, 'scopeId', 1, 256);
255
+ this.trustedChannelId = validateBoundedString(options.channelId, 'channelId', 1, 256);
256
+ if (options.sessionId?.harnessId !== 'flex') throw new BrowserRuntimeError('INVALID_INPUT');
257
+ this.trustedSessionId = {
258
+ harnessId: 'flex',
259
+ nativeId: validateBoundedString(options.sessionId.nativeId, 'sessionId.nativeId', 1, 256),
260
+ };
212
261
  if (
213
262
  !(options.readable instanceof plugins.stream.Readable)
214
263
  || !(options.writable instanceof plugins.stream.Writable)
@@ -229,6 +278,42 @@ export class BrowserRuntimeFramedServerPeer {
229
278
  return this.trustedPeerId;
230
279
  }
231
280
 
281
+ public get projectId(): string {
282
+ return this.trustedProjectId;
283
+ }
284
+
285
+ public get browserResourceId(): string {
286
+ return this.trustedBrowserResourceId;
287
+ }
288
+
289
+ public get channelId(): string {
290
+ return this.trustedChannelId;
291
+ }
292
+
293
+ /** @internal */
294
+ public get activeCapabilityId(): string | undefined {
295
+ return this.capabilityId ?? this.pendingCapabilityId;
296
+ }
297
+
298
+ /** @internal */
299
+ public releaseLeaseFromRuntime(capabilityId: string, leaseId: string): void {
300
+ if (
301
+ this.pendingCapabilityId === capabilityId
302
+ && !this.lease
303
+ ) {
304
+ this.pendingReleasedLeaseId = leaseId;
305
+ return;
306
+ }
307
+ if (
308
+ this.capabilityId !== capabilityId
309
+ || this.lease?.capabilityId !== capabilityId
310
+ || this.lease.leaseId !== leaseId
311
+ ) return;
312
+ this.lease = undefined;
313
+ this.capabilityId = undefined;
314
+ this.runtimeReleasedLease = true;
315
+ }
316
+
232
317
  public start(): void {
233
318
  if (this.started) return;
234
319
  this.started = true;
@@ -237,7 +322,17 @@ export class BrowserRuntimeFramedServerPeer {
237
322
 
238
323
  public close(): Promise<void> {
239
324
  if (this.closePromise) return this.closePromise;
240
- this.closePromise = this.closeInternal().catch((error) => {
325
+ this.closePromise = this.closeInternal(true).catch((error) => {
326
+ this.closePromise = undefined;
327
+ throw error;
328
+ });
329
+ return this.closePromise;
330
+ }
331
+
332
+ /** @internal */
333
+ public closeFromRuntime(): Promise<void> {
334
+ if (this.closePromise) return this.closePromise;
335
+ this.closePromise = this.closeInternal(false).catch((error) => {
241
336
  this.closePromise = undefined;
242
337
  throw error;
243
338
  });
@@ -281,36 +376,77 @@ export class BrowserRuntimeFramedServerPeer {
281
376
  request: Record<string, unknown>,
282
377
  controller: AbortController,
283
378
  ): Promise<void> {
379
+ let acquisitionCapabilityId: string | undefined;
284
380
  try {
285
381
  let result: unknown;
286
382
  if (method === 'acquire') {
287
383
  validateExactKeys(request, ['version', 'id', 'method', 'capabilityToken'], 'acquire request');
288
- if (this.lease) throw new BrowserRuntimeError('BUSY');
384
+ if (this.lease || this.pendingCapabilityId) throw new BrowserRuntimeError('BUSY');
289
385
  const capabilityToken = validateBoundedString(
290
386
  request.capabilityToken,
291
387
  'capabilityToken',
292
388
  16,
293
389
  512,
294
390
  );
295
- this.lease = await this.runtime.acquireLease({
296
- capabilityToken,
391
+ const authority = {
392
+ projectId: this.trustedProjectId,
393
+ browserResourceId: this.trustedBrowserResourceId,
394
+ attachmentAuthorityId: this.trustedAttachmentAuthorityId,
395
+ attachmentRevision: this.trustedAttachmentRevision,
396
+ actorId: this.trustedActorId,
297
397
  peerId: this.trustedPeerId,
298
- expectedRole: 'agent',
299
- expectedSource: 'flex',
398
+ role: this.trustedRole,
399
+ source: this.trustedSource,
300
400
  scopeId: this.trustedScopeId,
401
+ channelId: this.trustedChannelId,
301
402
  sessionId: this.trustedSessionId,
403
+ } as const;
404
+ const descriptor = this.runtime.authorizeCapability(capabilityToken, authority);
405
+ this.pendingCapabilityId = descriptor.capabilityId;
406
+ acquisitionCapabilityId = descriptor.capabilityId;
407
+ this.pendingReleasedLeaseId = undefined;
408
+ const acquiredLease = await this.runtime.acquireLease({
409
+ capabilityToken,
410
+ ...authority,
302
411
  signal: controller.signal,
303
412
  });
413
+ await this.runtime.beforeFramedPeerLeasePublication();
414
+ this.lease = acquiredLease;
415
+ this.capabilityId = acquiredLease.capabilityId;
416
+ this.runtimeReleasedLease = false;
417
+ const releasedBeforePublication = this.pendingReleasedLeaseId === acquiredLease.leaseId;
418
+ this.pendingCapabilityId = undefined;
419
+ this.pendingReleasedLeaseId = undefined;
420
+ if (releasedBeforePublication) {
421
+ this.lease = undefined;
422
+ this.capabilityId = undefined;
423
+ this.runtimeReleasedLease = true;
424
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
425
+ }
304
426
  if (this.closing || controller.signal.aborted) {
305
- await this.lease.release();
427
+ await this.runtime.revokeFramedCapability(acquiredLease.capabilityId);
306
428
  this.lease = undefined;
429
+ this.capabilityId = undefined;
307
430
  throw new BrowserRuntimeError('ABORTED');
308
431
  }
432
+ try {
433
+ acquiredLease.getState();
434
+ } catch {
435
+ this.lease = undefined;
436
+ this.capabilityId = undefined;
437
+ this.runtimeReleasedLease = true;
438
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
439
+ }
309
440
  result = {
310
- leaseId: this.lease.leaseId,
311
- role: this.lease.role,
441
+ leaseId: acquiredLease.leaseId,
442
+ role: acquiredLease.role,
443
+ projectId: this.trustedProjectId,
444
+ browserResourceId: this.trustedBrowserResourceId,
445
+ attachmentAuthorityId: this.trustedAttachmentAuthorityId,
446
+ attachmentRevision: this.trustedAttachmentRevision,
312
447
  scopeId: this.trustedScopeId,
313
- sessionId: this.trustedSessionId,
448
+ channelId: this.trustedChannelId,
449
+ sessionId: { ...this.trustedSessionId },
314
450
  };
315
451
  } else if (method === 'execute') {
316
452
  validateExactKeys(request, ['version', 'id', 'method', 'action'], 'execute request');
@@ -335,9 +471,14 @@ export class BrowserRuntimeFramedServerPeer {
335
471
  result = { cancelled };
336
472
  } else if (method === 'release') {
337
473
  validateExactKeys(request, ['version', 'id', 'method'], 'release request');
338
- const lease = this.requireLease();
339
- await lease.release();
474
+ if (!this.lease) {
475
+ if (!this.runtimeReleasedLease) throw new BrowserRuntimeError('CAPABILITY_INVALID');
476
+ } else {
477
+ await this.runtime.revokeFramedCapability(this.capabilityId!);
478
+ }
340
479
  this.lease = undefined;
480
+ this.capabilityId = undefined;
481
+ this.runtimeReleasedLease = false;
341
482
  result = { released: true };
342
483
  } else {
343
484
  throw new BrowserRuntimeError('PROTOCOL_ERROR');
@@ -345,6 +486,10 @@ export class BrowserRuntimeFramedServerPeer {
345
486
  assertJsonWithoutBytes(result, hardMaxFrameBytes);
346
487
  await this.channel.send({ version: protocolVersion, id, ok: true, result });
347
488
  } catch (error) {
489
+ if (acquisitionCapabilityId && this.pendingCapabilityId === acquisitionCapabilityId) {
490
+ this.pendingCapabilityId = undefined;
491
+ this.pendingReleasedLeaseId = undefined;
492
+ }
348
493
  await this.sendError(id, error);
349
494
  }
350
495
  }
@@ -366,21 +511,23 @@ export class BrowserRuntimeFramedServerPeer {
366
511
  } satisfies IFramedErrorResponse).catch(() => undefined);
367
512
  }
368
513
 
369
- private async closeInternal(): Promise<void> {
514
+ private async closeInternal(revokeCapability: boolean): Promise<void> {
370
515
  this.closing = true;
371
516
  for (const controller of this.activeRequests.values()) {
372
517
  controller.abort(new BrowserRuntimeError('ABORTED'));
373
518
  }
374
- const dispatchResult = await waitBounded(
375
- Promise.allSettled([...this.dispatches]).then(() => undefined),
376
- hardRequestTimeoutMs,
377
- );
378
- if (!dispatchResult.settled) {
379
- await this.runtime.disconnectPeer(this.trustedPeerId);
519
+ const capabilityId = this.capabilityId;
520
+ if (revokeCapability && capabilityId) {
521
+ await this.runtime.revokeFramedCapability(capabilityId);
380
522
  }
381
- await this.lease?.release();
382
523
  this.lease = undefined;
383
- await this.runtime.disconnectPeer(this.trustedPeerId);
524
+ this.capabilityId = undefined;
525
+ if (revokeCapability) {
526
+ await waitBounded(
527
+ Promise.allSettled([...this.dispatches]).then(() => undefined),
528
+ hardRequestTimeoutMs,
529
+ );
530
+ }
384
531
  await this.channel.close();
385
532
  this.onClosed();
386
533
  }
@@ -392,13 +539,10 @@ export class BrowserRuntimeFramedServerPeer {
392
539
  for (const controller of this.activeRequests.values()) {
393
540
  controller.abort(new BrowserRuntimeError('ABORTED'));
394
541
  }
395
- await waitBounded(
396
- Promise.allSettled([...this.dispatches]).then(() => undefined),
397
- hardRequestTimeoutMs,
398
- );
399
- await this.lease?.release();
542
+ const capabilityId = this.capabilityId;
543
+ if (capabilityId) await this.runtime.revokeFramedCapability(capabilityId);
400
544
  this.lease = undefined;
401
- await this.runtime.disconnectPeer(this.trustedPeerId);
545
+ this.capabilityId = undefined;
402
546
  this.onClosed();
403
547
  })().catch((error) => {
404
548
  this.closePromise = undefined;
@@ -417,11 +561,48 @@ export class BrowserRuntimeFramedClient {
417
561
  private started = false;
418
562
  private closePromise?: Promise<void>;
419
563
  private readonly scopeIdValue: string;
420
- private readonly sessionIdValue: string;
564
+ private readonly channelIdValue: string;
565
+ private readonly sessionIdValue: IBrowserFlexSessionId;
566
+ private readonly authority: TBrowserCapabilityAuthorizationRequest;
567
+ private readonly afterAcquireResponse?: IBrowserRuntimeFramedClientTestingOptions['afterAcquireResponse'];
421
568
 
422
- constructor(options: IBrowserRuntimeFramedClientOptions) {
423
- this.scopeIdValue = validateBoundedString(options.scopeId, 'scopeId', 1, 128);
424
- this.sessionIdValue = validateBoundedString(options.sessionId, 'sessionId', 1, 128);
569
+ constructor(options: IBrowserRuntimeFramedClientOptions | IBrowserRuntimeFramedClientTestingOptions) {
570
+ if (options.role !== 'agent' || options.source !== 'flex' || options.sessionId?.harnessId !== 'flex') {
571
+ throw new BrowserRuntimeError('INVALID_INPUT');
572
+ }
573
+ this.scopeIdValue = validateBoundedString(options.scopeId, 'scopeId', 1, 256);
574
+ this.channelIdValue = validateBoundedString(options.channelId, 'channelId', 1, 256);
575
+ this.sessionIdValue = {
576
+ harnessId: 'flex',
577
+ nativeId: validateBoundedString(options.sessionId.nativeId, 'sessionId.nativeId', 1, 256),
578
+ };
579
+ this.authority = {
580
+ projectId: validateBoundedString(options.projectId, 'projectId', 1, 256),
581
+ browserResourceId: validateBoundedString(options.browserResourceId, 'browserResourceId', 16, 256),
582
+ attachmentAuthorityId: validateBoundedString(
583
+ options.attachmentAuthorityId,
584
+ 'attachmentAuthorityId',
585
+ 1,
586
+ 256,
587
+ ),
588
+ attachmentRevision: validateInteger(
589
+ options.attachmentRevision,
590
+ 'attachmentRevision',
591
+ 0,
592
+ Number.MAX_SAFE_INTEGER,
593
+ ),
594
+ actorId: validateBoundedString(options.actorId, 'actorId', 1, 256),
595
+ role: 'agent',
596
+ peerId: validateBoundedString(options.peerId, 'peerId', 1, 256),
597
+ source: 'flex',
598
+ scopeId: this.scopeIdValue,
599
+ channelId: this.channelIdValue,
600
+ sessionId: this.sessionIdValue,
601
+ };
602
+ this.afterAcquireResponse = browserRuntimeTesting in options
603
+ && options[browserRuntimeTesting] === true
604
+ ? options.afterAcquireResponse
605
+ : undefined;
425
606
  if (
426
607
  !(options.readable instanceof plugins.stream.Readable)
427
608
  || !(options.writable instanceof plugins.stream.Writable)
@@ -462,12 +643,22 @@ export class BrowserRuntimeFramedClient {
462
643
  return this.scopeIdValue;
463
644
  }
464
645
 
465
- public get sessionId(): string {
466
- return this.sessionIdValue;
646
+ public get channelId(): string {
647
+ return this.channelIdValue;
648
+ }
649
+
650
+ public get sessionId(): IBrowserFlexSessionId {
651
+ return { ...this.sessionIdValue };
652
+ }
653
+
654
+ public get authorityBinding(): TBrowserCapabilityAuthorizationRequest {
655
+ return {
656
+ ...(this.authority as IBrowserFlexCapabilityBinding),
657
+ sessionId: { ...this.sessionIdValue },
658
+ };
467
659
  }
468
660
 
469
661
  public async acquire(capabilityTokenArg: string, signal?: AbortSignal): Promise<void> {
470
- if (this.acquired) throw new BrowserRuntimeError('BUSY');
471
662
  signal?.throwIfAborted();
472
663
  const capabilityToken = validateBoundedString(
473
664
  capabilityTokenArg,
@@ -482,10 +673,27 @@ export class BrowserRuntimeFramedClient {
482
673
  void this.request('cancel', { targetRequestId: id }).catch(() => undefined);
483
674
  };
484
675
  signal?.addEventListener('abort', onAbort, { once: true });
676
+ let acquiredRemotely = false;
485
677
  try {
486
678
  await acquisition;
679
+ acquiredRemotely = true;
680
+ await this.afterAcquireResponse?.();
487
681
  signal?.throwIfAborted();
488
682
  this.acquired = true;
683
+ } catch (error) {
684
+ if (acquiredRemotely) {
685
+ let cleanupError: unknown;
686
+ await this.request('release', {}).catch(async (releaseError) => {
687
+ cleanupError = releaseError;
688
+ await this.channel.close().catch(() => undefined);
689
+ this.handleClose();
690
+ });
691
+ this.acquired = false;
692
+ if (cleanupError) {
693
+ throw new AggregateError([error, cleanupError], 'Framed acquisition cleanup is incomplete');
694
+ }
695
+ }
696
+ throw error;
489
697
  } finally {
490
698
  signal?.removeEventListener('abort', onAbort);
491
699
  }