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