@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.
- package/.smartconfig.json +1 -1
- package/changelog.md +10 -0
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/actions.js +12 -3
- package/dist_ts/classes.artifactstore.d.ts +16 -7
- package/dist_ts/classes.artifactstore.js +167 -88
- package/dist_ts/classes.egressproxy.d.ts +2 -0
- package/dist_ts/classes.egressproxy.js +7 -2
- package/dist_ts/classes.flexprovider.js +4 -2
- package/dist_ts/classes.framed.d.ts +30 -3
- package/dist_ts/classes.framed.js +205 -30
- package/dist_ts/classes.runtime.d.ts +80 -22
- package/dist_ts/classes.runtime.js +876 -196
- package/dist_ts/errors.d.ts +1 -1
- package/dist_ts/errors.js +6 -3
- package/dist_ts/index.d.ts +2 -2
- package/dist_ts/interfaces.d.ts +102 -40
- package/dist_ts/internal.testing.d.ts +10 -1
- package/dist_ts/internal.testing.js +1 -1
- package/dist_ts/mcp.js +45 -11
- package/package.json +2 -2
- package/readme.hints.md +22 -18
- package/readme.md +69 -101
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/actions.ts +11 -2
- package/ts/classes.artifactstore.ts +239 -107
- package/ts/classes.egressproxy.ts +12 -1
- package/ts/classes.flexprovider.ts +5 -1
- package/ts/classes.framed.ts +246 -38
- package/ts/classes.runtime.ts +1145 -281
- package/ts/errors.ts +8 -2
- package/ts/index.ts +18 -6
- package/ts/interfaces.ts +127 -38
- package/ts/internal.testing.ts +12 -0
- package/ts/mcp.ts +67 -16
package/ts/classes.runtime.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
+
TBrowserCapabilityDescriptor,
|
|
18
|
+
TBrowserCapabilityAuthorizationRequest,
|
|
16
19
|
IBrowserConfinementProbeContext,
|
|
17
20
|
IBrowserObservationResult,
|
|
18
|
-
|
|
21
|
+
TBrowserRuntimeAuditEvent,
|
|
19
22
|
IBrowserRuntimeFrameSubscription,
|
|
20
23
|
IBrowserRuntimeOperationOptions,
|
|
21
24
|
IBrowserRuntimeOptions,
|
|
25
|
+
IBrowserResourceRegistration,
|
|
22
26
|
IBrowserRuntimeState,
|
|
23
27
|
IBrowserScreenshotResult,
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
106
|
+
capability: TCapabilityRecord;
|
|
90
107
|
digest: plugins.Buffer;
|
|
91
108
|
peerId: string;
|
|
92
109
|
role: TBrowserActorRole;
|
|
93
|
-
slot:
|
|
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
|
|
137
|
+
interface IResourceSlot {
|
|
120
138
|
projectId: string;
|
|
139
|
+
browserResourceId: string;
|
|
140
|
+
registeredAt: number;
|
|
141
|
+
attachmentBinding: IBrowserAttachmentBinding;
|
|
121
142
|
mutex: TransitionMutex;
|
|
122
|
-
|
|
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
|
|
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
|
|
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,
|
|
156
|
-
private readonly
|
|
157
|
-
private readonly
|
|
158
|
-
private readonly
|
|
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
|
-
|
|
224
|
-
options.
|
|
225
|
-
'
|
|
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
|
-
|
|
254
|
-
options.
|
|
255
|
-
'
|
|
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
|
-
|
|
303
|
-
options.
|
|
304
|
-
'
|
|
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:
|
|
378
|
-
): Promise<
|
|
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 =
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
390
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
627
|
+
: this.validateQualifiedSessionId(requestArg.sessionId);
|
|
409
628
|
if (
|
|
410
|
-
(
|
|
411
|
-
|| (
|
|
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
|
|
425
|
-
|
|
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 +
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
563
|
-
expectedRole,
|
|
564
|
-
expectedSource,
|
|
565
|
-
scopeId,
|
|
566
|
-
sessionId,
|
|
804
|
+
expected,
|
|
567
805
|
);
|
|
568
|
-
const slot = this.
|
|
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
|
-
|
|
577
|
-
|
|
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
|
|
583
|
-
const record = this.resolveCapability(digestToken(token),
|
|
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 =
|
|
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
|
|
633
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
841
|
-
await this.
|
|
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(
|
|
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(
|
|
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.
|
|
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:
|
|
973
|
-
capability:
|
|
1220
|
+
slot: IResourceSlot,
|
|
1221
|
+
capability: TCapabilityRecord,
|
|
974
1222
|
digest: plugins.Buffer,
|
|
975
|
-
acquisition:
|
|
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
|
-
|
|
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.
|
|
993
|
-
|
|
994
|
-
|
|
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.
|
|
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:
|
|
1012
|
-
capability:
|
|
1249
|
+
slot: IResourceSlot,
|
|
1250
|
+
capability: TCapabilityRecord,
|
|
1013
1251
|
digest: plugins.Buffer,
|
|
1014
|
-
acquisition:
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
1081
|
-
capability:
|
|
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:
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
1545
|
-
|
|
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.
|
|
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
|
-
|
|
1584
|
-
|
|
1850
|
+
slot,
|
|
1851
|
+
session!,
|
|
1585
1852
|
outstanding.acknowledgement,
|
|
1853
|
+
true,
|
|
1586
1854
|
));
|
|
1855
|
+
return;
|
|
1587
1856
|
}
|
|
1588
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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.
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
2087
|
+
const framedPeer = [...this.framedPeers].find((peer) => (
|
|
2088
|
+
peer.activeCapabilityId === capability.capabilityId
|
|
2089
|
+
));
|
|
1736
2090
|
if (framedPeer) {
|
|
1737
|
-
await framedPeer.
|
|
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
|
|
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 (
|
|
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)
|
|
2168
|
+
if (lease.releaseOwnsFence && slot.fencingGeneration === generation) {
|
|
2169
|
+
slot.fencingGeneration = undefined;
|
|
2170
|
+
}
|
|
1815
2171
|
lease.releaseGeneration = undefined;
|
|
1816
|
-
|
|
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:
|
|
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(
|
|
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
|
|
2249
|
+
private scheduleIdleTermination(slot: IResourceSlot): void {
|
|
2250
|
+
if (slot.terminationFenceGeneration !== undefined || slot.retirementPending) return;
|
|
1885
2251
|
if (slot.idleTimer) clearTimeout(slot.idleTimer);
|
|
1886
|
-
const
|
|
2252
|
+
const arbitrationGeneration = slot.arbitrationGeneration;
|
|
2253
|
+
const incarnationGeneration = slot.incarnationGeneration;
|
|
2254
|
+
const scheduledSession = slot.session;
|
|
1887
2255
|
slot.idleTimer = setTimeout(() => {
|
|
1888
|
-
|
|
1889
|
-
this.trackCleanup(this.
|
|
1890
|
-
|
|
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
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
-
|
|
2293
|
+
let generation = 0;
|
|
2294
|
+
let attachmentFenceOwnsTransition = false;
|
|
1911
2295
|
try {
|
|
1912
|
-
if (
|
|
1913
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
}
|