@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.
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import type {
3
3
  IBrowserArtifactMetadata,
4
4
  IBrowserArtifactStoreOptions,
5
+ IBrowserResourceKey,
5
6
  } from './interfaces.js';
6
7
  import { BrowserRuntimeError } from './errors.js';
7
8
  import {
@@ -16,15 +17,25 @@ interface IStoredArtifact {
16
17
  digest: string;
17
18
  }
18
19
 
19
- interface IProjectArtifacts {
20
+ interface IResourceArtifacts {
21
+ browserResourceId: string;
20
22
  directoryName: string;
21
23
  totalBytes: number;
22
24
  artifacts: Map<string, IStoredArtifact>;
23
25
  }
24
26
 
27
+ interface IProjectArtifacts {
28
+ directoryName: string;
29
+ totalBytes: number;
30
+ totalArtifacts: number;
31
+ resources: Map<string, IResourceArtifacts>;
32
+ }
33
+
25
34
  export class BrowserArtifactStore {
26
35
  private readonly rootDirectory: string;
27
36
  private readonly maxFileBytes: number;
37
+ private readonly maxArtifactsPerResource: number;
38
+ private readonly maxResourceBytes: number;
28
39
  private readonly maxArtifactsPerProject: number;
29
40
  private readonly maxProjectBytes: number;
30
41
  private readonly maxProjects: number;
@@ -55,38 +66,46 @@ export class BrowserArtifactStore {
55
66
  64 * 1024 * 1024,
56
67
  8 * 1024 * 1024,
57
68
  );
58
- this.maxArtifactsPerProject = validateOptionalInteger(
59
- options.maxArtifactsPerProject,
60
- 'maxArtifactsPerProject',
69
+ this.maxArtifactsPerResource = validateOptionalInteger(
70
+ options.maxArtifactsPerResource,
71
+ 'maxArtifactsPerResource',
61
72
  1,
62
73
  4096,
63
74
  256,
64
75
  );
65
- this.maxProjectBytes = validateOptionalInteger(
66
- options.maxProjectBytes,
67
- 'maxProjectBytes',
76
+ this.maxResourceBytes = validateOptionalInteger(
77
+ options.maxResourceBytes,
78
+ 'maxResourceBytes',
68
79
  this.maxFileBytes,
69
80
  1024 * 1024 * 1024,
70
81
  64 * 1024 * 1024,
71
82
  );
72
- this.maxProjects = validateOptionalInteger(
73
- options.maxProjects,
74
- 'maxProjects',
75
- 1,
76
- 256,
77
- 32,
83
+ this.maxArtifactsPerProject = validateOptionalInteger(
84
+ options.maxArtifactsPerProject,
85
+ 'maxArtifactsPerProject',
86
+ this.maxArtifactsPerResource,
87
+ 65_536,
88
+ Math.max(1024, this.maxArtifactsPerResource),
78
89
  );
90
+ this.maxProjectBytes = validateOptionalInteger(
91
+ options.maxProjectBytes,
92
+ 'maxProjectBytes',
93
+ this.maxResourceBytes,
94
+ 8 * 1024 * 1024 * 1024,
95
+ Math.max(256 * 1024 * 1024, this.maxResourceBytes),
96
+ );
97
+ this.maxProjects = validateOptionalInteger(options.maxProjects, 'maxProjects', 1, 256, 32);
79
98
  this.maxTotalArtifacts = validateOptionalInteger(
80
99
  options.maxTotalArtifacts,
81
100
  'maxTotalArtifacts',
82
- this.maxArtifactsPerProject,
101
+ 1,
83
102
  65_536,
84
103
  Math.max(4096, this.maxArtifactsPerProject),
85
104
  );
86
105
  this.maxTotalBytes = validateOptionalInteger(
87
106
  options.maxTotalBytes,
88
107
  'maxTotalBytes',
89
- this.maxProjectBytes,
108
+ this.maxFileBytes,
90
109
  8 * 1024 * 1024 * 1024,
91
110
  Math.max(512 * 1024 * 1024, this.maxProjectBytes),
92
111
  );
@@ -104,9 +123,7 @@ export class BrowserArtifactStore {
104
123
  public start(): Promise<void> {
105
124
  if (this.started) return Promise.resolve();
106
125
  if (this.startPromise) return this.startPromise;
107
- this.startPromise = this.startInternal().finally(() => {
108
- this.startPromise = undefined;
109
- });
126
+ this.startPromise = this.startInternal().finally(() => { this.startPromise = undefined; });
110
127
  return this.startPromise;
111
128
  }
112
129
 
@@ -127,19 +144,19 @@ export class BrowserArtifactStore {
127
144
  } finally {
128
145
  release();
129
146
  }
130
- })().finally(() => {
131
- this.stopPromise = undefined;
132
- });
147
+ })().finally(() => { this.stopPromise = undefined; });
133
148
  return this.stopPromise;
134
149
  }
135
150
 
136
151
  public async store(
137
152
  projectIdArg: string,
153
+ browserResourceIdArg: string,
138
154
  mimeTypeArg: string,
139
155
  data: Uint8Array,
140
156
  ): Promise<IBrowserArtifactMetadata> {
141
157
  this.requireStarted();
142
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
158
+ const projectId = this.validateProjectId(projectIdArg);
159
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
143
160
  const mimeType = validateBoundedString(mimeTypeArg, 'mimeType', 1, 128);
144
161
  if (!(data instanceof Uint8Array)) {
145
162
  throw new BrowserRuntimeError('INVALID_INPUT', 'data must be Uint8Array');
@@ -149,7 +166,7 @@ export class BrowserArtifactStore {
149
166
  const release = await this.mutex.acquire();
150
167
  try {
151
168
  this.requireStarted();
152
- await this.purgeExpiredInternal(projectId);
169
+ await this.purgeExpiredInternal({ projectId, browserResourceId });
153
170
  const existingProject = this.projects.get(projectId);
154
171
  if (!existingProject && this.projects.size >= this.maxProjects) {
155
172
  throw new BrowserRuntimeError('QUOTA_EXCEEDED');
@@ -160,15 +177,22 @@ export class BrowserArtifactStore {
160
177
  ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
161
178
  const project = existingProject ?? await this.createProject(projectId);
162
179
  if (
163
- project.artifacts.size >= this.maxArtifactsPerProject
180
+ project.totalArtifacts >= this.maxArtifactsPerProject
164
181
  || project.totalBytes + bytes.byteLength > this.maxProjectBytes
165
182
  ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
183
+ const resource = project.resources.get(browserResourceId)
184
+ ?? await this.createResource(project, projectId, browserResourceId);
185
+ if (
186
+ resource.artifacts.size >= this.maxArtifactsPerResource
187
+ || resource.totalBytes + bytes.byteLength > this.maxResourceBytes
188
+ ) throw new BrowserRuntimeError('QUOTA_EXCEEDED');
166
189
 
167
190
  const artifactId = randomId();
168
191
  const createdAt = this.now();
169
192
  const metadata: IBrowserArtifactMetadata = {
170
193
  artifactId,
171
194
  projectId,
195
+ browserResourceId,
172
196
  mimeType,
173
197
  size: bytes.byteLength,
174
198
  createdAt,
@@ -178,9 +202,9 @@ export class BrowserArtifactStore {
178
202
  metadata,
179
203
  digest: plugins.crypto.createHash('sha256').update(bytes).digest('hex'),
180
204
  };
181
- const projectDirectory = plugins.path.join(this.rootDirectory, project.directoryName);
182
- const finalPath = plugins.path.join(projectDirectory, artifactId);
183
- const temporaryPath = plugins.path.join(projectDirectory, `.${randomId(12)}.tmp`);
205
+ const resourceDirectory = this.resourcePath(project, resource);
206
+ const finalPath = plugins.path.join(resourceDirectory, artifactId);
207
+ const temporaryPath = plugins.path.join(resourceDirectory, `.${randomId(12)}.tmp`);
184
208
  let handle: plugins.fsPromises.FileHandle | undefined;
185
209
  let renamed = false;
186
210
  try {
@@ -192,7 +216,9 @@ export class BrowserArtifactStore {
192
216
  await plugins.fsPromises.rename(temporaryPath, finalPath);
193
217
  renamed = true;
194
218
  await plugins.fsPromises.chmod(finalPath, 0o600);
195
- project.artifacts.set(artifactId, stored);
219
+ resource.artifacts.set(artifactId, stored);
220
+ resource.totalBytes += bytes.byteLength;
221
+ project.totalArtifacts += 1;
196
222
  project.totalBytes += bytes.byteLength;
197
223
  this.totalArtifacts += 1;
198
224
  this.totalBytes += bytes.byteLength;
@@ -201,7 +227,9 @@ export class BrowserArtifactStore {
201
227
  await handle?.close().catch(() => undefined);
202
228
  await plugins.fsPromises.rm(temporaryPath, { force: true }).catch(() => undefined);
203
229
  if (renamed) await plugins.fsPromises.rm(finalPath, { force: true }).catch(() => undefined);
204
- if (project.artifacts.size === 0) await this.removeEmptyProject(projectId, project);
230
+ if (resource.artifacts.size === 0) {
231
+ await this.removeEmptyResource(projectId, project, browserResourceId, resource);
232
+ }
205
233
  throw error;
206
234
  }
207
235
  } finally {
@@ -209,27 +237,36 @@ export class BrowserArtifactStore {
209
237
  }
210
238
  }
211
239
 
212
- public async read(projectIdArg: string, artifactIdArg: string): Promise<Uint8Array> {
240
+ public async read(
241
+ projectIdArg: string,
242
+ browserResourceIdArg: string,
243
+ artifactIdArg: string,
244
+ ): Promise<Uint8Array> {
213
245
  this.requireStarted();
214
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
215
- const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
246
+ const projectId = this.validateProjectId(projectIdArg);
247
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
248
+ const artifactId = this.validateArtifactId(artifactIdArg);
216
249
  const release = await this.mutex.acquire();
217
250
  let handle: plugins.fsPromises.FileHandle | undefined;
218
251
  try {
219
- const { project, stored, filePath } = this.resolveArtifact(projectId, artifactId);
220
- if (stored.metadata.expiresAt <= this.now()) {
221
- await this.deleteArtifact(projectId, project, stored, filePath);
252
+ const resolved = this.resolveArtifact(projectId, browserResourceId, artifactId);
253
+ if (resolved.stored.metadata.expiresAt <= this.now()) {
254
+ await this.deleteArtifact(projectId, browserResourceId, resolved);
222
255
  throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
223
256
  }
224
257
  handle = await plugins.fsPromises.open(
225
- filePath,
258
+ resolved.filePath,
226
259
  plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW,
227
260
  );
228
261
  const stat = await handle.stat();
229
- if (!stat.isFile() || stat.size !== stored.metadata.size || stat.size > this.maxFileBytes) {
262
+ if (
263
+ !stat.isFile()
264
+ || stat.size !== resolved.stored.metadata.size
265
+ || stat.size > this.maxFileBytes
266
+ ) {
230
267
  await handle.close();
231
268
  handle = undefined;
232
- await this.deleteArtifact(projectId, project, stored, filePath);
269
+ await this.deleteArtifact(projectId, browserResourceId, resolved);
233
270
  throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
234
271
  }
235
272
  const data = plugins.Buffer.allocUnsafe(stat.size);
@@ -237,8 +274,8 @@ export class BrowserArtifactStore {
237
274
  await handle.close();
238
275
  handle = undefined;
239
276
  const digest = plugins.crypto.createHash('sha256').update(data).digest('hex');
240
- if (bytesRead !== stat.size || digest !== stored.digest) {
241
- await this.deleteArtifact(projectId, project, stored, filePath);
277
+ if (bytesRead !== stat.size || digest !== resolved.stored.digest) {
278
+ await this.deleteArtifact(projectId, browserResourceId, resolved);
242
279
  throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
243
280
  }
244
281
  return new Uint8Array(data);
@@ -248,72 +285,90 @@ export class BrowserArtifactStore {
248
285
  }
249
286
  }
250
287
 
251
- public async delete(projectIdArg: string, artifactIdArg: string): Promise<void> {
288
+ public async delete(
289
+ projectIdArg: string,
290
+ browserResourceIdArg: string,
291
+ artifactIdArg: string,
292
+ ): Promise<void> {
252
293
  this.requireStarted();
253
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
254
- const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
294
+ const projectId = this.validateProjectId(projectIdArg);
295
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
296
+ const artifactId = this.validateArtifactId(artifactIdArg);
255
297
  const release = await this.mutex.acquire();
256
298
  try {
257
- const { project, stored, filePath } = this.resolveArtifact(projectId, artifactId);
258
- await this.deleteArtifact(projectId, project, stored, filePath);
299
+ await this.deleteArtifact(
300
+ projectId,
301
+ browserResourceId,
302
+ this.resolveArtifact(projectId, browserResourceId, artifactId),
303
+ );
259
304
  } finally {
260
305
  release();
261
306
  }
262
307
  }
263
308
 
264
- public async purgeExpired(projectIdArg?: string): Promise<number> {
309
+ public async purgeExpired(resource?: IBrowserResourceKey): Promise<number> {
265
310
  this.requireStarted();
266
- const projectId = projectIdArg === undefined
267
- ? undefined
268
- : validateBoundedString(projectIdArg, 'projectId', 1, 128);
311
+ const validated = resource === undefined ? undefined : {
312
+ projectId: this.validateProjectId(resource.projectId),
313
+ browserResourceId: this.validateResourceId(resource.browserResourceId),
314
+ };
269
315
  const release = await this.mutex.acquire();
270
316
  try {
271
- return await this.purgeExpiredInternal(projectId);
317
+ return await this.purgeExpiredInternal(validated);
272
318
  } finally {
273
319
  release();
274
320
  }
275
321
  }
276
322
 
277
- public async purgeProject(projectIdArg: string): Promise<void> {
323
+ public async purgeResource(projectIdArg: string, browserResourceIdArg: string): Promise<void> {
278
324
  this.requireStarted();
279
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
325
+ const projectId = this.validateProjectId(projectIdArg);
326
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
280
327
  const release = await this.mutex.acquire();
281
328
  try {
282
329
  const project = this.projects.get(projectId);
283
- if (!project) return;
284
- await plugins.fsPromises.rm(
285
- plugins.path.join(this.rootDirectory, project.directoryName),
286
- { recursive: true, force: true },
287
- );
288
- this.totalArtifacts -= project.artifacts.size;
289
- this.totalBytes -= project.totalBytes;
290
- this.projects.delete(projectId);
330
+ const resource = project?.resources.get(browserResourceId);
331
+ if (!project || !resource) return;
332
+ await plugins.fsPromises.rm(this.resourcePath(project, resource), {
333
+ recursive: true,
334
+ force: true,
335
+ });
336
+ project.totalArtifacts -= resource.artifacts.size;
337
+ project.totalBytes -= resource.totalBytes;
338
+ this.totalArtifacts -= resource.artifacts.size;
339
+ this.totalBytes -= resource.totalBytes;
340
+ project.resources.delete(browserResourceId);
341
+ await this.removeEmptyProject(projectId, project);
291
342
  } finally {
292
343
  release();
293
344
  }
294
345
  }
295
346
 
296
- public getMetadata(projectIdArg: string, artifactIdArg: string): IBrowserArtifactMetadata {
347
+ public getMetadata(
348
+ projectIdArg: string,
349
+ browserResourceIdArg: string,
350
+ artifactIdArg: string,
351
+ ): IBrowserArtifactMetadata {
297
352
  this.requireStarted();
298
- const projectId = validateBoundedString(projectIdArg, 'projectId', 1, 128);
299
- const artifactId = validateBoundedString(artifactIdArg, 'artifactId', 1, 128);
300
- const project = this.projects.get(projectId);
301
- const stored = project?.artifacts.get(artifactId);
302
- if (!stored || stored.metadata.expiresAt <= this.now()) {
353
+ const projectId = this.validateProjectId(projectIdArg);
354
+ const browserResourceId = this.validateResourceId(browserResourceIdArg);
355
+ const artifactId = this.validateArtifactId(artifactIdArg);
356
+ const resolved = this.resolveArtifact(projectId, browserResourceId, artifactId);
357
+ if (resolved.stored.metadata.expiresAt <= this.now()) {
303
358
  throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
304
359
  }
305
360
  try {
306
- const stat = plugins.fs.lstatSync(
307
- plugins.path.join(this.rootDirectory, project!.directoryName, artifactId),
308
- );
309
- if (stat.isSymbolicLink() || !stat.isFile() || stat.size !== stored.metadata.size) {
310
- throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
311
- }
361
+ const stat = plugins.fs.lstatSync(resolved.filePath);
362
+ if (
363
+ stat.isSymbolicLink()
364
+ || !stat.isFile()
365
+ || stat.size !== resolved.stored.metadata.size
366
+ ) throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
312
367
  } catch (error) {
313
368
  if (error instanceof BrowserRuntimeError) throw error;
314
369
  throw new BrowserRuntimeError('FENCED', 'artifact integrity check failed');
315
370
  }
316
- return { ...stored.metadata };
371
+ return { ...resolved.stored.metadata };
317
372
  }
318
373
 
319
374
  private async startInternal(): Promise<void> {
@@ -337,8 +392,6 @@ export class BrowserArtifactStore {
337
392
  || (uid !== undefined && stat.uid !== uid)
338
393
  || (stat.mode & 0o777) !== 0o700
339
394
  ) throw new BrowserRuntimeError('FENCED', 'artifact root is not private');
340
- const entries = await plugins.fsPromises.readdir(this.rootDirectory);
341
- if (entries.length > 0) throw new BrowserRuntimeError('FENCED', 'artifact root is not empty');
342
395
  await plugins.fsPromises.chmod(this.rootDirectory, 0o700);
343
396
  this.started = true;
344
397
  const sweepIntervalMs = Math.min(60_000, Math.max(1000, Math.floor(this.artifactTtlMs / 2)));
@@ -357,32 +410,63 @@ export class BrowserArtifactStore {
357
410
  }
358
411
 
359
412
  private async createProject(projectId: string): Promise<IProjectArtifacts> {
360
- const directoryName = plugins.crypto
361
- .createHmac('sha256', this.directoryKey)
362
- .update(projectId, 'utf8')
363
- .digest('hex');
364
- const projectDirectory = plugins.path.join(this.rootDirectory, directoryName);
365
- await plugins.fsPromises.mkdir(projectDirectory, { recursive: false, mode: 0o700 });
366
- const project = { directoryName, totalBytes: 0, artifacts: new Map() };
413
+ const directoryName = this.digestDirectory(`project\0${projectId}`);
414
+ await plugins.fsPromises.mkdir(plugins.path.join(this.rootDirectory, directoryName), {
415
+ recursive: false,
416
+ mode: 0o700,
417
+ });
418
+ const project: IProjectArtifacts = {
419
+ directoryName,
420
+ totalBytes: 0,
421
+ totalArtifacts: 0,
422
+ resources: new Map(),
423
+ };
367
424
  this.projects.set(projectId, project);
368
425
  return project;
369
426
  }
370
427
 
371
- private async purgeExpiredInternal(projectId?: string): Promise<number> {
372
- const projectIds = projectId === undefined ? [...this.projects.keys()] : [projectId];
428
+ private async createResource(
429
+ project: IProjectArtifacts,
430
+ projectId: string,
431
+ browserResourceId: string,
432
+ ): Promise<IResourceArtifacts> {
433
+ const directoryName = this.digestDirectory(`resource\0${projectId}\0${browserResourceId}`);
434
+ await plugins.fsPromises.mkdir(
435
+ plugins.path.join(this.rootDirectory, project.directoryName, directoryName),
436
+ { recursive: false, mode: 0o700 },
437
+ );
438
+ const resource: IResourceArtifacts = {
439
+ browserResourceId,
440
+ directoryName,
441
+ totalBytes: 0,
442
+ artifacts: new Map(),
443
+ };
444
+ project.resources.set(browserResourceId, resource);
445
+ return resource;
446
+ }
447
+
448
+ private async purgeExpiredInternal(resourceKey?: IBrowserResourceKey): Promise<number> {
449
+ const projectEntries = resourceKey
450
+ ? [[resourceKey.projectId, this.projects.get(resourceKey.projectId)] as const]
451
+ : [...this.projects.entries()];
373
452
  let purged = 0;
374
- for (const currentProjectId of projectIds) {
375
- const project = this.projects.get(currentProjectId);
453
+ for (const [projectId, project] of projectEntries) {
376
454
  if (!project) continue;
377
- for (const stored of [...project.artifacts.values()]) {
378
- if (stored.metadata.expiresAt <= this.now()) {
379
- const filePath = plugins.path.join(
380
- this.rootDirectory,
381
- project.directoryName,
382
- stored.metadata.artifactId,
383
- );
384
- await this.deleteArtifact(currentProjectId, project, stored, filePath);
385
- purged += 1;
455
+ const resourceEntries = resourceKey
456
+ ? [[resourceKey.browserResourceId, project.resources.get(resourceKey.browserResourceId)] as const]
457
+ : [...project.resources.entries()];
458
+ for (const [browserResourceId, resource] of resourceEntries) {
459
+ if (!resource) continue;
460
+ for (const stored of [...resource.artifacts.values()]) {
461
+ if (stored.metadata.expiresAt <= this.now()) {
462
+ await this.deleteArtifact(projectId, browserResourceId, {
463
+ project,
464
+ resource,
465
+ stored,
466
+ filePath: plugins.path.join(this.resourcePath(project, resource), stored.metadata.artifactId),
467
+ });
468
+ purged += 1;
469
+ }
386
470
  }
387
471
  }
388
472
  }
@@ -391,21 +475,46 @@ export class BrowserArtifactStore {
391
475
 
392
476
  private async deleteArtifact(
393
477
  projectId: string,
394
- project: IProjectArtifacts,
395
- stored: IStoredArtifact,
396
- filePath: string,
478
+ browserResourceId: string,
479
+ resolved: ReturnType<BrowserArtifactStore['resolveArtifact']>,
397
480
  ): Promise<void> {
398
- await plugins.fsPromises.rm(filePath, { force: true });
399
- if (project.artifacts.delete(stored.metadata.artifactId)) {
400
- project.totalBytes = Math.max(0, project.totalBytes - stored.metadata.size);
481
+ await plugins.fsPromises.rm(resolved.filePath, { force: true });
482
+ if (resolved.resource.artifacts.delete(resolved.stored.metadata.artifactId)) {
483
+ const size = resolved.stored.metadata.size;
484
+ resolved.resource.totalBytes = Math.max(0, resolved.resource.totalBytes - size);
485
+ resolved.project.totalArtifacts = Math.max(0, resolved.project.totalArtifacts - 1);
486
+ resolved.project.totalBytes = Math.max(0, resolved.project.totalBytes - size);
401
487
  this.totalArtifacts = Math.max(0, this.totalArtifacts - 1);
402
- this.totalBytes = Math.max(0, this.totalBytes - stored.metadata.size);
488
+ this.totalBytes = Math.max(0, this.totalBytes - size);
403
489
  }
490
+ await this.removeEmptyResource(
491
+ projectId,
492
+ resolved.project,
493
+ browserResourceId,
494
+ resolved.resource,
495
+ );
496
+ }
497
+
498
+ private async removeEmptyResource(
499
+ projectId: string,
500
+ project: IProjectArtifacts,
501
+ browserResourceId: string,
502
+ resource: IResourceArtifacts,
503
+ ): Promise<void> {
504
+ if (
505
+ resource.artifacts.size > 0
506
+ || project.resources.get(browserResourceId) !== resource
507
+ ) return;
508
+ await plugins.fsPromises.rm(this.resourcePath(project, resource), {
509
+ recursive: true,
510
+ force: true,
511
+ });
512
+ project.resources.delete(browserResourceId);
404
513
  await this.removeEmptyProject(projectId, project);
405
514
  }
406
515
 
407
516
  private async removeEmptyProject(projectId: string, project: IProjectArtifacts): Promise<void> {
408
- if (project.artifacts.size > 0 || this.projects.get(projectId) !== project) return;
517
+ if (project.resources.size > 0 || this.projects.get(projectId) !== project) return;
409
518
  await plugins.fsPromises.rm(
410
519
  plugins.path.join(this.rootDirectory, project.directoryName),
411
520
  { recursive: true, force: true },
@@ -413,20 +522,43 @@ export class BrowserArtifactStore {
413
522
  this.projects.delete(projectId);
414
523
  }
415
524
 
416
- private resolveArtifact(projectId: string, artifactId: string): {
525
+ private resolveArtifact(projectId: string, browserResourceId: string, artifactId: string): {
417
526
  project: IProjectArtifacts;
527
+ resource: IResourceArtifacts;
418
528
  stored: IStoredArtifact;
419
529
  filePath: string;
420
530
  } {
421
531
  const project = this.projects.get(projectId);
422
- const stored = project?.artifacts.get(artifactId);
423
- if (!project || !stored) {
532
+ const resource = project?.resources.get(browserResourceId);
533
+ const stored = resource?.artifacts.get(artifactId);
534
+ if (!project || !resource || !stored) {
424
535
  throw new BrowserRuntimeError('INVALID_INPUT', 'artifact is unavailable');
425
536
  }
426
537
  return {
427
538
  project,
539
+ resource,
428
540
  stored,
429
- filePath: plugins.path.join(this.rootDirectory, project.directoryName, artifactId),
541
+ filePath: plugins.path.join(this.resourcePath(project, resource), artifactId),
430
542
  };
431
543
  }
544
+
545
+ private resourcePath(project: IProjectArtifacts, resource: IResourceArtifacts): string {
546
+ return plugins.path.join(this.rootDirectory, project.directoryName, resource.directoryName);
547
+ }
548
+
549
+ private digestDirectory(value: string): string {
550
+ return plugins.crypto.createHmac('sha256', this.directoryKey).update(value, 'utf8').digest('hex');
551
+ }
552
+
553
+ private validateProjectId(value: unknown): string {
554
+ return validateBoundedString(value, 'projectId', 1, 256);
555
+ }
556
+
557
+ private validateResourceId(value: unknown): string {
558
+ return validateBoundedString(value, 'browserResourceId', 16, 256);
559
+ }
560
+
561
+ private validateArtifactId(value: unknown): string {
562
+ return validateBoundedString(value, 'artifactId', 1, 128);
563
+ }
432
564
  }
@@ -38,6 +38,7 @@ const hopByHopHeaders = new Set([
38
38
 
39
39
  export class BrowserEgressProxy {
40
40
  private readonly projectId: string;
41
+ private readonly browserResourceIdValue: string;
41
42
  private readonly allowedPorts: ReadonlySet<number>;
42
43
  private readonly maxConnections: number;
43
44
  private readonly maxActiveRequests: number;
@@ -69,7 +70,13 @@ export class BrowserEgressProxy {
69
70
  private rejectedRequests = 0;
70
71
 
71
72
  constructor(options: IBrowserEgressProxyOptions) {
72
- this.projectId = validateBoundedString(options.projectId, 'projectId', 1, 128);
73
+ this.projectId = validateBoundedString(options.projectId, 'projectId', 1, 256);
74
+ this.browserResourceIdValue = validateBoundedString(
75
+ options.browserResourceId,
76
+ 'browserResourceId',
77
+ 16,
78
+ 256,
79
+ );
73
80
  const ports = options.allowedPorts ?? [80, 443];
74
81
  if (!Array.isArray(ports) || ports.length < 1 || ports.length > 32) {
75
82
  throw new BrowserRuntimeError('INVALID_INPUT', 'allowedPorts must contain 1 to 32 ports');
@@ -178,6 +185,10 @@ export class BrowserEgressProxy {
178
185
  return this.projectId;
179
186
  }
180
187
 
188
+ public get browserResourceId(): string {
189
+ return this.browserResourceIdValue;
190
+ }
191
+
181
192
  public get allowedTargetPorts(): number[] {
182
193
  return [...this.allowedPorts];
183
194
  }
@@ -36,7 +36,11 @@ implements plugins.flexharness.IFlexToolProvider<TScope> {
36
36
  this.state = 'provisioning';
37
37
  const scopeId = validateBoundedString(context.scopeId, 'scopeId', 1, 128);
38
38
  const sessionId = validateBoundedString(context.sessionId, 'sessionId', 1, 128);
39
- if (this.client.scopeId !== scopeId || this.client.sessionId !== sessionId) {
39
+ if (
40
+ this.client.scopeId !== scopeId
41
+ || this.client.sessionId.harnessId !== 'flex'
42
+ || this.client.sessionId.nativeId !== sessionId
43
+ ) {
40
44
  this.state = 'closed';
41
45
  throw new BrowserRuntimeError('CAPABILITY_INVALID');
42
46
  }