@modelprofile.com/browser-runtime 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import { validateAgentAction } from './actions.js';
3
3
  import { BrowserArtifactStore } from './classes.artifactstore.js';
4
4
  import { BrowserEgressProxy } from './classes.egressproxy.js';
5
+ import { BrowserRuntimeOwnership } from './classes.runtimeownership.js';
5
6
  import { runProductionConfinementProbe } from './confinement.js';
6
7
  import { BrowserRuntimeError } from './errors.js';
7
8
  import {
@@ -89,6 +90,7 @@ interface INormalizedRuntimeOptions {
89
90
  maxOutstandingFrames: number;
90
91
  frameAcknowledgementTimeoutMs: number;
91
92
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
93
+ ownership?: IBrowserRuntimeTestingOptions['ownership'];
92
94
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
93
95
  beforeIdleTermination?: IBrowserRuntimeTestingOptions['beforeIdleTermination'];
94
96
  beforeFrameFailureTermination?: IBrowserRuntimeTestingOptions['beforeFrameFailureTermination'];
@@ -233,9 +235,8 @@ const identifierMaximum = 256;
233
235
 
234
236
  export class BrowserRuntime {
235
237
  private readonly options: INormalizedRuntimeOptions;
236
- private readonly profileRoot: string;
237
- private readonly artifactRoot: string;
238
- private readonly lockPath: string;
238
+ private readonly ownership: BrowserRuntimeOwnership;
239
+ private profileRoot?: string;
239
240
  private readonly artifactStore: BrowserArtifactStore;
240
241
  private readonly runtimeAuthorityId = randomId(18);
241
242
  private readonly slots = new Map<string, IResourceSlot>();
@@ -247,11 +248,10 @@ export class BrowserRuntime {
247
248
  private readonly capabilityMutex = new TransitionMutex();
248
249
  private readonly cleanupOperations = new Set<Promise<void>>();
249
250
  private readonly framedPeers = new Set<BrowserRuntimeFramedServerPeer>();
250
- private lockHandle?: plugins.fsPromises.FileHandle;
251
- private lockHandleClosed = false;
252
251
  private startPromise?: Promise<void>;
253
252
  private stopPromise?: Promise<void>;
254
253
  private stopCleanupPromise?: Promise<void>;
254
+ private localCleanupPending = false;
255
255
  private lifecycleState: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
256
256
  private lifecycleEpoch = 0;
257
257
  private lifecycleController = new AbortController();
@@ -473,17 +473,21 @@ export class BrowserRuntime {
473
473
  10_000,
474
474
  ),
475
475
  egress: options.egress ?? {},
476
+ ownership: testingOptions?.ownership,
476
477
  beforeLeasePublication: testingOptions?.beforeLeasePublication,
477
478
  beforeIdleTermination: testingOptions?.beforeIdleTermination,
478
479
  beforeFrameFailureTermination: testingOptions?.beforeFrameFailureTermination,
479
480
  afterFrameFailureFence: testingOptions?.afterFrameFailureFence,
480
481
  beforeFramedPeerLeasePublication: testingOptions?.beforeFramedPeerLeasePublication,
481
482
  };
482
- this.profileRoot = plugins.path.join(options.runtimeDirectory, 'profiles');
483
- this.artifactRoot = plugins.path.join(options.runtimeDirectory, 'artifacts');
484
- this.lockPath = plugins.path.join(options.runtimeDirectory, 'runtime.lock');
483
+ this.ownership = new BrowserRuntimeOwnership({
484
+ runtimeDirectory: options.runtimeDirectory,
485
+ uid: environment.uid ?? -1,
486
+ testing: testingOptions?.ownership,
487
+ onOwnershipLost: () => this.handleOwnershipLost(),
488
+ });
485
489
  this.artifactStore = new BrowserArtifactStore({
486
- rootDirectory: this.artifactRoot,
490
+ rootDirectory: plugins.path.join(options.runtimeDirectory, 'artifacts'),
487
491
  ...(options.artifacts ?? {}),
488
492
  });
489
493
  }
@@ -492,6 +496,13 @@ export class BrowserRuntime {
492
496
  if (this.lifecycleState === 'running') return Promise.resolve();
493
497
  if (this.startPromise) return this.startPromise;
494
498
  if (this.stopCleanupPromise) return this.stopCleanupPromise.then(() => this.start());
499
+ if (
500
+ this.lifecycleState === 'stopping'
501
+ || this.localCleanupPending
502
+ || this.ownership.cleanupPending
503
+ ) {
504
+ return this.stop().then(() => this.start());
505
+ }
495
506
  this.lifecycleState = 'starting';
496
507
  const epoch = ++this.lifecycleEpoch;
497
508
  this.lifecycleController = new AbortController();
@@ -515,7 +526,8 @@ export class BrowserRuntime {
515
526
  if (this.stopPromise) return this.stopPromise;
516
527
  if (
517
528
  this.lifecycleState === 'stopped'
518
- && !this.lockHandle
529
+ && !this.localCleanupPending
530
+ && !this.ownership.cleanupPending
519
531
  && !this.startPromise
520
532
  && !this.stopCleanupPromise
521
533
  ) {
@@ -528,8 +540,13 @@ export class BrowserRuntime {
528
540
  const startup = this.startPromise;
529
541
  const cleanup = (async () => {
530
542
  await startup?.catch(() => undefined);
531
- await this.stopInternal();
532
- this.lifecycleState = 'stopped';
543
+ try {
544
+ await this.stopInternal();
545
+ } finally {
546
+ if (!this.localCleanupPending && !this.ownership.cleanupPending) {
547
+ this.lifecycleState = 'stopped';
548
+ }
549
+ }
533
550
  })();
534
551
  this.stopCleanupPromise = cleanup;
535
552
  void cleanup.then(
@@ -1321,7 +1338,7 @@ export class BrowserRuntime {
1321
1338
  ): Promise<Uint8Array> {
1322
1339
  this.requireHuman(lease);
1323
1340
  this.requireValidLease(lease);
1324
- return this.artifactStore.read(
1341
+ return this.requireArtifactStore().read(
1325
1342
  lease.capability.projectId,
1326
1343
  lease.capability.browserResourceId,
1327
1344
  artifactId,
@@ -1332,7 +1349,7 @@ export class BrowserRuntime {
1332
1349
  public async deleteLeaseArtifact(lease: ILeaseRecord, artifactId: string): Promise<void> {
1333
1350
  this.requireHuman(lease);
1334
1351
  this.requireValidLease(lease);
1335
- await this.artifactStore.delete(
1352
+ await this.requireArtifactStore().delete(
1336
1353
  lease.capability.projectId,
1337
1354
  lease.capability.browserResourceId,
1338
1355
  artifactId,
@@ -1360,30 +1377,31 @@ export class BrowserRuntime {
1360
1377
  if (this.options.environment.uid === undefined || this.options.environment.uid === 0) {
1361
1378
  throw new BrowserRuntimeError('FENCED', 'browser runtime requires a non-root uid');
1362
1379
  }
1363
- await plugins.fsPromises.mkdir(this.options.runtimeDirectory, { recursive: true, mode: 0o700 });
1364
- await plugins.fsPromises.chmod(this.options.runtimeDirectory, 0o700);
1365
- if (!this.lockHandle) {
1380
+ const generation = await this.ownership.acquire();
1381
+ this.profileRoot = generation.profileRoot;
1382
+ try {
1383
+ this.artifactStore.setRootDirectoryForRuntime(generation.artifactRoot);
1384
+ await plugins.fsPromises.mkdir(this.profileRoot, { recursive: false, mode: 0o700 });
1385
+ await this.secureNewPrivateDirectory(this.profileRoot);
1386
+ await this.artifactStore.start();
1387
+ } catch (error) {
1366
1388
  try {
1367
- this.lockHandle = await plugins.fsPromises.open(this.lockPath, 'wx', 0o600);
1368
- this.lockHandleClosed = false;
1369
- await this.lockHandle.chmod(0o600);
1370
- } catch (error) {
1371
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
1372
- throw new BrowserRuntimeError('LOCKED');
1373
- }
1374
- throw error;
1389
+ await this.ownership.release('startup-failure');
1390
+ this.profileRoot = undefined;
1391
+ } catch {
1392
+ throw new BrowserRuntimeError('FENCED', 'runtime startup cleanup is incomplete');
1375
1393
  }
1394
+ throw error;
1376
1395
  }
1377
- await plugins.fsPromises.mkdir(this.profileRoot, { recursive: true, mode: 0o700 });
1378
- await plugins.fsPromises.chmod(this.profileRoot, 0o700);
1379
- const profileEntries = await plugins.fsPromises.readdir(this.profileRoot);
1380
- if (profileEntries.length > 0) {
1381
- throw new BrowserRuntimeError('FENCED', 'profile root is not empty');
1382
- }
1383
- await this.artifactStore.start();
1384
1396
  }
1385
1397
 
1386
1398
  private async stopInternal(): Promise<void> {
1399
+ this.localCleanupPending = true;
1400
+ const errors: unknown[] = [];
1401
+ const localErrors: unknown[] = [];
1402
+ if (this.ownership.owned) {
1403
+ await this.ownership.assertCurrentOwnership().catch((error) => errors.push(error));
1404
+ }
1387
1405
  const peerResults = await Promise.allSettled(
1388
1406
  [...this.framedPeers].map((peer) => peer.close()),
1389
1407
  );
@@ -1402,34 +1420,98 @@ export class BrowserRuntime {
1402
1420
  while (this.cleanupOperations.size > 0) {
1403
1421
  await Promise.allSettled([...this.cleanupOperations]);
1404
1422
  }
1405
- const errors = [...peerResults, ...revokeResults, ...retirementResults]
1423
+ localErrors.push(...[...peerResults, ...revokeResults, ...retirementResults]
1406
1424
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
1407
- .map((result) => result.reason);
1425
+ .map((result) => result.reason));
1426
+ await this.artifactStore.stop().catch((error) => localErrors.push(error));
1427
+ errors.push(...localErrors);
1428
+ await this.ownership.release('stop').catch((error) => errors.push(error));
1429
+ if (localErrors.length === 0) {
1430
+ this.localCleanupPending = false;
1431
+ this.clearStoppedRuntimeState();
1432
+ }
1408
1433
  if (errors.length > 0) {
1409
1434
  throw new AggregateError(errors, 'Browser runtime cleanup is incomplete');
1410
1435
  }
1411
- await this.artifactStore.stop();
1412
- if (this.lockHandle) {
1413
- if (!this.lockHandleClosed) {
1414
- await this.lockHandle.close();
1415
- this.lockHandleClosed = true;
1416
- }
1417
- await plugins.fsPromises.rm(this.lockPath, { force: true });
1418
- this.lockHandle = undefined;
1419
- this.lockHandleClosed = false;
1420
- }
1436
+ }
1437
+
1438
+ private clearStoppedRuntimeState(): void {
1439
+ this.profileRoot = undefined;
1421
1440
  this.slots.clear();
1441
+ this.capabilitiesByDigest.clear();
1442
+ this.capabilitiesById.clear();
1443
+ this.failedRevocations.clear();
1444
+ this.framedPeers.clear();
1422
1445
  this.retiredResourceIds.fill(0);
1423
1446
  this.retiredResourceKeys.fill(0);
1424
1447
  this.startPromise = undefined;
1425
1448
  }
1426
1449
 
1450
+ private async secureNewPrivateDirectory(pathArg: string): Promise<void> {
1451
+ const uid = this.options.environment.uid;
1452
+ if (uid === undefined) throw new BrowserRuntimeError('FENCED');
1453
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1454
+ const handle = await plugins.fsPromises.open(
1455
+ pathArg,
1456
+ plugins.fs.constants.O_RDONLY
1457
+ | plugins.fs.constants.O_DIRECTORY
1458
+ | plugins.fs.constants.O_NOFOLLOW,
1459
+ );
1460
+ try {
1461
+ await handle.chmod(0o700);
1462
+ await plugins.fsPromises.chmod(pathArg, 0o700);
1463
+ const [pathStat, handleStat] = await Promise.all([
1464
+ plugins.fsPromises.lstat(pathArg, { bigint: true }),
1465
+ handle.stat({ bigint: true }),
1466
+ ]);
1467
+ if (
1468
+ !pathStat.isDirectory()
1469
+ || pathStat.isSymbolicLink()
1470
+ || pathStat.uid !== BigInt(uid)
1471
+ || (pathStat.mode & 0o777n) !== 0o700n
1472
+ || pathStat.dev !== handleStat.dev
1473
+ || pathStat.ino !== handleStat.ino
1474
+ ) throw new BrowserRuntimeError('FENCED', 'runtime directory is not private');
1475
+ } finally {
1476
+ await handle.close();
1477
+ }
1478
+ }
1479
+
1427
1480
  private requireRunning(): void {
1428
- if (this.lifecycleState !== 'running' || this.lifecycleController.signal.aborted) {
1481
+ if (
1482
+ this.lifecycleState !== 'running'
1483
+ || this.lifecycleController.signal.aborted
1484
+ || !this.ownership.owned
1485
+ ) {
1429
1486
  throw new BrowserRuntimeError('NOT_RUNNING');
1430
1487
  }
1431
1488
  }
1432
1489
 
1490
+ private requireArtifactStore(): BrowserArtifactStore {
1491
+ this.requireRunning();
1492
+ return this.artifactStore;
1493
+ }
1494
+
1495
+ private handleOwnershipLost(): void {
1496
+ if (this.lifecycleState === 'stopped' || this.lifecycleState === 'stopping') return;
1497
+ this.lifecycleEpoch += 1;
1498
+ this.lifecycleController.abort(new BrowserRuntimeError('FENCED'));
1499
+ this.lifecycleState = 'stopping';
1500
+ void this.stopAfterOwnershipLoss();
1501
+ }
1502
+
1503
+ private async stopAfterOwnershipLoss(): Promise<void> {
1504
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1505
+ try {
1506
+ await this.stop();
1507
+ return;
1508
+ } catch {
1509
+ if (!this.localCleanupPending && !this.ownership.cleanupPending) return;
1510
+ await Promise.resolve();
1511
+ }
1512
+ }
1513
+ }
1514
+
1433
1515
  private async acquireAgentLease(
1434
1516
  slot: IResourceSlot,
1435
1517
  capability: TCapabilityRecord,
@@ -1570,7 +1652,9 @@ export class BrowserRuntime {
1570
1652
  if (slot.session) return;
1571
1653
  this.assertSlotAvailable(slot);
1572
1654
  this.reserveLaunch(slot);
1573
- const profileDirectory = plugins.path.join(this.profileRoot, randomId(24));
1655
+ const profileRoot = this.profileRoot;
1656
+ if (!profileRoot) throw new BrowserRuntimeError('FENCED');
1657
+ const profileDirectory = plugins.path.join(profileRoot, randomId(24));
1574
1658
  let profileCreated = false;
1575
1659
  let proxy: BrowserEgressProxy | undefined;
1576
1660
  let session: ILiveBrowserSessionLike | undefined;
@@ -1578,7 +1662,7 @@ export class BrowserRuntime {
1578
1662
  try {
1579
1663
  await plugins.fsPromises.mkdir(profileDirectory, { mode: 0o700 });
1580
1664
  profileCreated = true;
1581
- await plugins.fsPromises.chmod(profileDirectory, 0o700);
1665
+ await this.secureNewPrivateDirectory(profileDirectory);
1582
1666
  proxy = new BrowserEgressProxy({
1583
1667
  projectId: slot.projectId,
1584
1668
  browserResourceId: slot.browserResourceId,
@@ -2199,7 +2283,7 @@ export class BrowserRuntime {
2199
2283
  signal.throwIfAborted();
2200
2284
  this.requireValidLease(lease);
2201
2285
  if (lease.slot.session !== session) throw new BrowserRuntimeError('ABORTED');
2202
- const artifact = await this.artifactStore.store(
2286
+ const artifact = await this.requireArtifactStore().store(
2203
2287
  lease.capability.projectId,
2204
2288
  lease.capability.browserResourceId,
2205
2289
  snapshot.mimeType,
@@ -2277,6 +2361,7 @@ export class BrowserRuntime {
2277
2361
  lastError: {
2278
2362
  code: truncateString(state.lastError.code, 128),
2279
2363
  fatal: state.lastError.fatal,
2364
+ message: truncateString(state.lastError.message, 2048),
2280
2365
  ...(state.lastError.tabId
2281
2366
  ? { tabId: truncateString(state.lastError.tabId, 128) }
2282
2367
  : {}),
@@ -2712,26 +2797,52 @@ export class BrowserRuntime {
2712
2797
  }
2713
2798
  return;
2714
2799
  }
2715
- if (event.type === 'error' && event.error.fatal && slot.lease) {
2716
- if (refreshForSession) {
2717
- this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2718
- } else {
2719
- this.trackCleanup(this.revokeCapabilityRecord(slot.lease.capability));
2720
- }
2721
- }
2722
- if (!subscription || subscription.closed) return;
2723
- if (event.type === 'state') {
2724
- this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
2725
- } else {
2726
- this.pushToSubscription(subscription, {
2800
+ if (event.type === 'error') {
2801
+ const runtimeEvent = {
2727
2802
  type: 'error',
2728
2803
  error: {
2729
2804
  code: truncateString(event.error.code, 128),
2730
2805
  fatal: event.error.fatal,
2806
+ message: truncateString(event.error.message, 2048),
2731
2807
  ...(event.error.tabId ? { tabId: truncateString(event.error.tabId, 128) } : {}),
2732
2808
  },
2733
- });
2809
+ } satisfies TBrowserRuntimeEvent;
2810
+ let fatalLease = event.error.fatal && slot.lease?.capability.state === 'active'
2811
+ ? slot.lease
2812
+ : undefined;
2813
+ if (fatalLease) {
2814
+ try {
2815
+ this.requireValidLease(fatalLease);
2816
+ } catch {
2817
+ fatalLease = undefined;
2818
+ }
2819
+ }
2820
+ if (event.error.fatal && !fatalLease) return;
2821
+ if (fatalLease) {
2822
+ const fatalSubscription = subscription
2823
+ && !subscription.closed
2824
+ && subscription.lease === fatalLease
2825
+ ? subscription
2826
+ : undefined;
2827
+ if (refreshForSession?.lease === fatalLease) {
2828
+ this.failFrameRefresh(refreshForSession, 'FRAME_STREAM_FAILED');
2829
+ }
2830
+ this.invalidateCapabilityRecord(fatalLease.capability);
2831
+ try {
2832
+ fatalSubscription?.listener(runtimeEvent);
2833
+ } catch {
2834
+ // Fatal revocation already owns cleanup for a failing listener.
2835
+ } finally {
2836
+ this.trackCleanup(this.revokeCapabilityRecord(fatalLease.capability));
2837
+ }
2838
+ return;
2839
+ }
2840
+ if (!subscription || subscription.closed) return;
2841
+ this.pushToSubscription(subscription, runtimeEvent);
2842
+ return;
2734
2843
  }
2844
+ if (!subscription || subscription.closed) return;
2845
+ this.pushToSubscription(subscription, { type: 'state', state: this.resourceState(event.state) });
2735
2846
  }
2736
2847
 
2737
2848
  private pushToSubscription(