@modelprofile.com/browser-runtime 2.0.0 → 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.
@@ -8,4 +8,4 @@ export { BrowserRuntimeError } from './errors.js';
8
8
  export { createBrowserRuntimeMcpHttpHandler } from './mcp.js';
9
9
  export type { TAcquireBrowserRuntimeLeaseOptions } from './classes.runtime.js';
10
10
  export type { TBrowserRuntimeErrorCode } from './errors.js';
11
- export type { IAttachTrustedFramedPeerOptions, IApplyBrowserAttachmentBindingRequest, IBrowserAttachmentBinding, IBrowserActionStateResult, IBrowserArtifactMetadata, IBrowserArtifactStoreOptions, IBrowserMcpAuthenticatedBinding, TBrowserCapabilityAuthorizationRequest, TBrowserCapabilityDescriptor, IBrowserClickAction, IBrowserEgressConnectionOptions, IBrowserEgressProxyOptions, IBrowserEgressProxyStats, IBrowserFlexCapabilityBinding, IBrowserFlexSessionId, IBrowserFillAction, IBrowserNavigateAction, IBrowserObservationResult, IBrowserOpenCodeSessionId, IBrowserPressAction, TBrowserRuntimeAuditEvent, IBrowserRuntimeFlexToolProviderOptions, IBrowserRuntimeFrameSubscription, IBrowserRuntimeFramedClientOptions, IBrowserRuntimeMcpOptions, IBrowserRuntimeOperationOptions, IBrowserRuntimeOptions, IBrowserResourceKey, IBrowserResourceRegistration, IBrowserRuntimeState, IBrowserRuntimeTabState, IBrowserScreenshotAction, IBrowserScreenshotResult, IBrowserSnapshotAction, ICreateBrowserResourceRequest, TIssueBrowserCapabilityRequest, TIssuedBrowserCapability, TQualifiedBrowserSessionId, IRegisterBrowserResourceRequest, IResolvedBrowserFlexCapability, TBrowserActorRole, TBrowserAgentAction, TBrowserAgentActionResult, TBrowserCapabilitySource, TBrowserDnsResolver, TBrowserEgressConnector, TBrowserHarnessId, TBrowserRuntimeEvent, } from './interfaces.js';
11
+ export type { IAttachTrustedFramedPeerOptions, IApplyBrowserAttachmentBindingRequest, IBrowserAttachmentBinding, IBrowserActionStateResult, IBrowserArtifactMetadata, IBrowserArtifactStoreOptions, IBrowserMcpAuthenticatedBinding, TBrowserCapabilityAuthorizationRequest, TBrowserCapabilityDescriptor, IBrowserClickAction, IBrowserEgressConnectionOptions, IBrowserEgressProxyOptions, IBrowserEgressProxyStats, IBrowserFlexCapabilityBinding, IBrowserFlexSessionId, IBrowserFillAction, IBrowserNavigateAction, IBrowserObservationResult, IBrowserOpenCodeSessionId, IBrowserPressAction, TBrowserRuntimeAuditEvent, TBrowserRuntimeOperationIdentity, IBrowserRuntimeFlexToolProviderOptions, IBrowserRuntimeFrameSubscription, IBrowserRuntimeFramedClientOptions, IBrowserRuntimeMcpOptions, IBrowserRuntimeOperationOptions, IBrowserRuntimeOptions, IBrowserResourceKey, IBrowserResourceRegistration, IBrowserRuntimeState, IBrowserRuntimeTabState, IBrowserScreenshotAction, IBrowserScreenshotResult, IBrowserSnapshotAction, ICreateBrowserResourceRequest, TIssueBrowserCapabilityRequest, TIssuedBrowserCapability, TQualifiedBrowserSessionId, IRegisterBrowserResourceRequest, IResolvedBrowserFlexCapability, TBrowserActorRole, TBrowserAgentAction, TBrowserAgentActionResult, TBrowserCapabilitySource, TBrowserDnsResolver, TBrowserEgressConnector, TBrowserHarnessId, TBrowserRuntimeEvent, } from './interfaces.js';
@@ -81,15 +81,22 @@ export type TIssuedBrowserCapability = TBrowserCapabilityDescriptor & {
81
81
  export interface IResolvedBrowserFlexCapability {
82
82
  capabilityToken: string;
83
83
  }
84
- export type TBrowserRuntimeAuditEvent = TBrowserCapabilityAuthorizationRequest & {
85
- operationId: string;
86
- capabilityId: string;
87
- leaseId: string;
88
- action: string;
89
- phase: 'completed' | 'failed';
90
- startedAt: number;
91
- finishedAt: number;
92
- errorCode?: string;
84
+ type TReadonlyBrowserCapabilityAuthorizationRequest = Readonly<IBrowserHumanCapabilityBinding> | (Omit<Readonly<IBrowserFlexCapabilityBinding>, 'sessionId'> & {
85
+ readonly sessionId: Readonly<IBrowserFlexSessionId>;
86
+ }) | (Omit<Readonly<IBrowserMcpCapabilityBinding>, 'sessionId'> & {
87
+ readonly sessionId: Readonly<IBrowserOpenCodeSessionId>;
88
+ });
89
+ export type TBrowserRuntimeOperationIdentity = TReadonlyBrowserCapabilityAuthorizationRequest & {
90
+ readonly operationId: string;
91
+ readonly capabilityId: string;
92
+ readonly leaseId: string;
93
+ readonly action: string;
94
+ readonly startedAt: number;
95
+ };
96
+ export type TBrowserRuntimeAuditEvent = TBrowserRuntimeOperationIdentity & {
97
+ readonly phase: 'completed' | 'failed';
98
+ readonly finishedAt: number;
99
+ readonly errorCode?: string;
93
100
  };
94
101
  export interface ILiveBrowserSessionLike {
95
102
  start(options?: plugins.smartpuppeteer.ILiveBrowserOperationOptions): Promise<void>;
@@ -130,6 +137,7 @@ export interface IBrowserRuntimeEnvironment {
130
137
  export interface IBrowserRuntimeOptions {
131
138
  runtimeDirectory: string;
132
139
  authorizeCapability(request: TBrowserCapabilityAuthorizationRequest, signal: AbortSignal): Promise<boolean> | boolean;
140
+ beforeOperation?(operation: TBrowserRuntimeOperationIdentity, signal: AbortSignal): Promise<void> | void;
133
141
  audit?(event: TBrowserRuntimeAuditEvent, signal: AbortSignal): Promise<void> | void;
134
142
  maxResources?: number;
135
143
  maxResourcesPerProject?: number;
@@ -141,6 +149,7 @@ export interface IBrowserRuntimeOptions {
141
149
  capabilityDefaultTtlMs?: number;
142
150
  capabilityMaximumTtlMs?: number;
143
151
  authorizationTimeoutMs?: number;
152
+ beforeOperationTimeoutMs?: number;
144
153
  maxTabsPerResource?: number;
145
154
  operationTimeoutMs?: number;
146
155
  quiescenceTimeoutMs?: number;
@@ -347,3 +356,4 @@ export interface IBrowserRuntimeFlexToolProviderOptions<TScope> {
347
356
  client: BrowserRuntimeFramedClient;
348
357
  resolveCapability(context: plugins.flexharness.IFlexToolProviderContext<TScope>): Promise<IResolvedBrowserFlexCapability> | IResolvedBrowserFlexCapability;
349
358
  }
359
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/browser-runtime",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Parent-owned, resource-centric Chromium runtime with revisioned attachment fencing, authenticated human and agent control, fail-closed egress, bounded artifacts, and Flex/MCP adapters.",
6
6
  "main": "dist_ts/index.js",
package/readme.hints.md CHANGED
@@ -17,6 +17,7 @@ Durable implementation findings for `@modelprofile.com/browser-runtime`.
17
17
  - Agent capabilities require the exact current non-detached qualified session. Human capabilities bind the exact resource, project, attachment authority, and revision but intentionally carry no session ID and may be issued while detached. Any attachment advance revokes both human and agent capabilities.
18
18
  - Capability tokens are returned once. Runtime records retain only SHA-256 digests and use `timingSafeEqual`. Authorization is rechecked after asynchronous host authorization, during lease acquisition, and before and after every operation.
19
19
  - Capability, audit, and lease identity includes immutable project, resource, attachment authority and revision, actor, peer, role, source, qualified agent session, and Flex scope/channel where applicable.
20
+ - `beforeOperation` reserves the exact resource and lets the host persist policy/audit through an awaited fail-closed gate. Runtime then atomically revalidates lease, attachment, session, arbitration generation, and incarnation before starting the side effect. Terminal audit remains best effort, uses the same operation ID, and runs after bounded cleanup releases or fences the exact reservation.
20
21
 
21
22
  ## Browser confinement
22
23
 
package/readme.md CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  const runtime = new BrowserRuntime({
28
28
  runtimeDirectory: '/var/lib/example/browser-runtime',
29
29
  authorizeCapability: async (binding) => hostPolicy.authorizeBrowser(binding),
30
+ beforeOperation: async (operation) => hostAudit.recordAttempt(operation),
30
31
  });
31
32
 
32
33
  try {
@@ -96,6 +97,8 @@ Agent capabilities require the exact current non-detached qualified session. Hum
96
97
 
97
98
  Agent actions are exactly `navigate`, `snapshot`, `screenshot`, `click`, `fill`, and `press`. Human leases additionally expose tab lifecycle, viewport, raw input, frame subscription/acknowledgement, and exact-resource artifact reads/deletes. JavaScript evaluation is not public.
98
99
 
100
+ `beforeOperation` is an optional awaited fail-closed gate. It receives the complete immutable authority, operation/capability/lease IDs, action, start time, and an `AbortSignal` after resource admission but before the browser side effect starts. Hosts that require durable attempt-before-side-effect auditing should persist the attempt there. Rejection denies the operation. The default `beforeOperationTimeoutMs` is 10,000 milliseconds and accepts values from 100 through 120,000; timeout fails with `TIMEOUT`. The terminal `audit` callback remains a best-effort `completed`/`failed` notification correlated by the same operation ID and runs after bounded operation cleanup releases or fences the exact reservation.
101
+
99
102
  ## Trusted Pipe And Flex
100
103
 
101
104
  Trusted framed peers and clients receive the complete authority out of band: project, resource, attachment authority/revision, actor, peer, role, source, qualified session, Flex scope, and resource-specific channel. Incoming frames cannot select identity. One session may use multiple resource-specific channels concurrently.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/browser-runtime',
6
- version: '2.0.0',
6
+ version: '2.1.0',
7
7
  description: 'Parent-owned, resource-centric Chromium runtime with revisioned attachment fencing, authenticated human and agent control, fail-closed egress, bounded artifacts, and Flex/MCP adapters.'
8
8
  }
@@ -19,6 +19,7 @@ import type {
19
19
  IBrowserConfinementProbeContext,
20
20
  IBrowserObservationResult,
21
21
  TBrowserRuntimeAuditEvent,
22
+ TBrowserRuntimeOperationIdentity,
22
23
  IBrowserRuntimeFrameSubscription,
23
24
  IBrowserRuntimeOperationOptions,
24
25
  IBrowserRuntimeOptions,
@@ -53,6 +54,7 @@ import { BrowserRuntimeFramedServerPeer } from './classes.framed.js';
53
54
  interface INormalizedRuntimeOptions {
54
55
  runtimeDirectory: string;
55
56
  authorizeCapability: IBrowserRuntimeOptions['authorizeCapability'];
57
+ beforeOperation?: IBrowserRuntimeOptions['beforeOperation'];
56
58
  audit?: IBrowserRuntimeOptions['audit'];
57
59
  sessionFactory: (
58
60
  options: plugins.smartpuppeteer.ILiveBrowserSessionOptions,
@@ -69,6 +71,7 @@ interface INormalizedRuntimeOptions {
69
71
  capabilityDefaultTtlMs: number;
70
72
  capabilityMaximumTtlMs: number;
71
73
  authorizationTimeoutMs: number;
74
+ beforeOperationTimeoutMs: number;
72
75
  maxTabsPerResource: number;
73
76
  operationTimeoutMs: number;
74
77
  quiescenceTimeoutMs: number;
@@ -121,6 +124,8 @@ interface IOperationRecord {
121
124
  controller: AbortController;
122
125
  action: string;
123
126
  startedAt: number;
127
+ session: ILiveBrowserSessionLike;
128
+ incarnationGeneration: number;
124
129
  promise: Promise<void>;
125
130
  }
126
131
 
@@ -212,6 +217,9 @@ export class BrowserRuntime {
212
217
  if (options.audit !== undefined && typeof options.audit !== 'function') {
213
218
  throw new BrowserRuntimeError('INVALID_INPUT', 'audit must be a function');
214
219
  }
220
+ if (options.beforeOperation !== undefined && typeof options.beforeOperation !== 'function') {
221
+ throw new BrowserRuntimeError('INVALID_INPUT', 'beforeOperation must be a function');
222
+ }
215
223
  const testingOptions = browserRuntimeTesting in options
216
224
  && options[browserRuntimeTesting] === true
217
225
  ? options as IBrowserRuntimeTestingOptions
@@ -244,6 +252,7 @@ export class BrowserRuntime {
244
252
  this.options = {
245
253
  runtimeDirectory: options.runtimeDirectory,
246
254
  authorizeCapability: options.authorizeCapability,
255
+ beforeOperation: options.beforeOperation,
247
256
  audit: options.audit,
248
257
  sessionFactory: testingOptions?.sessionFactory ?? ((sessionOptions) => (
249
258
  new plugins.smartpuppeteer.LiveBrowserSession(sessionOptions)
@@ -308,6 +317,13 @@ export class BrowserRuntime {
308
317
  120_000,
309
318
  10_000,
310
319
  ),
320
+ beforeOperationTimeoutMs: validateOptionalInteger(
321
+ options.beforeOperationTimeoutMs,
322
+ 'beforeOperationTimeoutMs',
323
+ 100,
324
+ 120_000,
325
+ 10_000,
326
+ ),
311
327
  maxTabsPerResource: validateOptionalInteger(
312
328
  options.maxTabsPerResource,
313
329
  'maxTabsPerResource',
@@ -1504,16 +1520,20 @@ export class BrowserRuntime {
1504
1520
  operationOptions.onOperationStarted !== undefined
1505
1521
  && typeof operationOptions.onOperationStarted !== 'function'
1506
1522
  ) throw new BrowserRuntimeError('INVALID_INPUT');
1507
- operationOptions.signal?.throwIfAborted();
1523
+ if (operationOptions.signal?.aborted) throw new BrowserRuntimeError('ABORTED');
1508
1524
  const slot = lease.slot;
1509
1525
  const release = slot.mutex.tryAcquire();
1510
1526
  if (!release) throw new BrowserRuntimeError('BUSY');
1511
1527
  let operation: IOperationRecord;
1512
- let executionPromise: Promise<T>;
1528
+ let executionPromise: Promise<T> | undefined;
1513
1529
  let combinedSignal: AbortSignal;
1514
1530
  let timeout: ReturnType<typeof setTimeout>;
1515
1531
  let session: ILiveBrowserSessionLike;
1516
1532
  let generation: number;
1533
+ let incarnationGeneration: number;
1534
+ let externalAbort = false;
1535
+ let onExternalAbort: (() => void) | undefined;
1536
+ let resolvePreflight!: () => void;
1517
1537
  const startedAt = Date.now();
1518
1538
  try {
1519
1539
  this.requireValidLease(lease);
@@ -1521,6 +1541,7 @@ export class BrowserRuntime {
1521
1541
  if (slot.operation) throw new BrowserRuntimeError('BUSY');
1522
1542
  session = slot.session!;
1523
1543
  generation = slot.arbitrationGeneration;
1544
+ incarnationGeneration = slot.incarnationGeneration;
1524
1545
  const timeoutMs = operationOptions.timeoutMs === undefined
1525
1546
  ? this.options.operationTimeoutMs
1526
1547
  : validateInteger(operationOptions.timeoutMs, 'timeoutMs', 100, 120_000);
@@ -1530,20 +1551,25 @@ export class BrowserRuntime {
1530
1551
  timeoutController.abort(new BrowserRuntimeError('TIMEOUT'));
1531
1552
  }, timeoutMs);
1532
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;
1533
1558
  combinedSignal = AbortSignal.any([
1534
1559
  controller.signal,
1535
1560
  lease.controller.signal,
1536
- operationOptions.signal ?? new AbortController().signal,
1561
+ externalSignal ?? new AbortController().signal,
1537
1562
  timeoutController.signal,
1538
1563
  ]);
1539
- executionPromise = Promise.resolve().then(() => execute(combinedSignal, session));
1540
1564
  operation = {
1541
1565
  operationId: randomId(18),
1542
1566
  lease,
1543
1567
  controller,
1544
1568
  action,
1545
1569
  startedAt,
1546
- promise: executionPromise.then(() => undefined, () => undefined),
1570
+ session,
1571
+ incarnationGeneration,
1572
+ promise: new Promise<void>((resolve) => { resolvePreflight = resolve; }),
1547
1573
  };
1548
1574
  slot.operation = operation;
1549
1575
  try {
@@ -1555,17 +1581,39 @@ export class BrowserRuntime {
1555
1581
  release();
1556
1582
  }
1557
1583
 
1558
- let phase: 'completed' | 'failed' = 'completed';
1559
- let errorCode: string | undefined;
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;
1560
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
+ }
1561
1610
  let onAbort!: () => void;
1562
1611
  const aborted = new Promise<never>((_resolve, reject) => {
1563
1612
  onAbort = () => reject(combinedSignal.reason ?? new BrowserRuntimeError('ABORTED'));
1564
1613
  combinedSignal.addEventListener('abort', onAbort, { once: true });
1565
1614
  });
1566
- let result: T;
1567
1615
  try {
1568
- result = await Promise.race([executionPromise!, aborted]);
1616
+ result = await Promise.race([executionPromise, aborted]);
1569
1617
  } finally {
1570
1618
  combinedSignal.removeEventListener('abort', onAbort);
1571
1619
  }
@@ -1575,46 +1623,131 @@ export class BrowserRuntime {
1575
1623
  if (slot.session !== session! || slot.arbitrationGeneration !== generation!) {
1576
1624
  throw new BrowserRuntimeError('ABORTED');
1577
1625
  }
1578
- return result;
1579
1626
  } catch (error) {
1580
- phase = 'failed';
1581
- errorCode = error instanceof BrowserRuntimeError
1582
- ? error.code
1583
- : error instanceof Error
1584
- ? truncateString(error.name, 64)
1585
- : 'Error';
1586
- if (combinedSignal!.aborted) {
1587
- this.trackCleanup(this.enforceOperationQuiescence(operation!));
1588
- const reason = combinedSignal!.reason;
1589
- throw reason instanceof BrowserRuntimeError
1590
- ? reason
1591
- : new BrowserRuntimeError(
1592
- reason instanceof Error && reason.name === 'TimeoutError' ? 'TIMEOUT' : 'ABORTED',
1593
- );
1594
- }
1595
- throw error;
1627
+ failed = true;
1628
+ failure = this.normalizeOperationError(error, combinedSignal!, externalAbort);
1596
1629
  } finally {
1597
1630
  clearTimeout(timeout!);
1598
- executionPromise!.finally(async () => {
1631
+ operationOptions.signal?.removeEventListener('abort', onExternalAbort!);
1632
+ resolvePreflight();
1633
+ const cleanupSlot = async (): Promise<void> => {
1599
1634
  const cleanupRelease = await slot.mutex.acquire();
1600
1635
  try {
1601
- if (slot.operation === operation!) slot.operation = undefined;
1636
+ if (slot.operation === operation) slot.operation = undefined;
1602
1637
  } finally {
1603
1638
  cleanupRelease();
1604
1639
  }
1605
- }).catch(() => undefined);
1606
- await this.emitAudit({
1607
- ...this.describeCapabilityIdentity(lease.capability),
1608
- operationId: operation!.operationId,
1609
- capabilityId: lease.capability.capabilityId,
1610
- leaseId: lease.leaseId,
1611
- action,
1612
- phase,
1613
- startedAt,
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',
1614
1656
  finishedAt: Date.now(),
1615
1657
  ...(errorCode ? { errorCode } : {}),
1616
- });
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
+ );
1676
+ }
1677
+ if (errorArg instanceof BrowserRuntimeError) return errorArg;
1678
+ if (errorArg instanceof Error && errorArg.name === 'TimeoutError') {
1679
+ return new BrowserRuntimeError('TIMEOUT');
1617
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;
1618
1751
  }
1619
1752
 
1620
1753
  private async executeAgentActionInternal(
@@ -2192,9 +2325,42 @@ export class BrowserRuntime {
2192
2325
  }
2193
2326
 
2194
2327
  private async enforceOperationQuiescence(operation: IOperationRecord): Promise<void> {
2195
- const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
2196
- if (settled.settled || operation.lease.released) return;
2197
- await this.revokeCapabilityRecord(operation.lease.capability);
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;
2198
2364
  }
2199
2365
 
2200
2366
  private async terminateSlotSession(
package/ts/index.ts CHANGED
@@ -34,6 +34,7 @@ export type {
34
34
  IBrowserOpenCodeSessionId,
35
35
  IBrowserPressAction,
36
36
  TBrowserRuntimeAuditEvent,
37
+ TBrowserRuntimeOperationIdentity,
37
38
  IBrowserRuntimeFlexToolProviderOptions,
38
39
  IBrowserRuntimeFrameSubscription,
39
40
  IBrowserRuntimeFramedClientOptions,
package/ts/interfaces.ts CHANGED
@@ -104,15 +104,27 @@ export interface IResolvedBrowserFlexCapability {
104
104
  capabilityToken: string;
105
105
  }
106
106
 
107
- export type TBrowserRuntimeAuditEvent = TBrowserCapabilityAuthorizationRequest & {
108
- operationId: string;
109
- capabilityId: string;
110
- leaseId: string;
111
- action: string;
112
- phase: 'completed' | 'failed';
113
- startedAt: number;
114
- finishedAt: number;
115
- errorCode?: string;
107
+ type TReadonlyBrowserCapabilityAuthorizationRequest =
108
+ | Readonly<IBrowserHumanCapabilityBinding>
109
+ | (Omit<Readonly<IBrowserFlexCapabilityBinding>, 'sessionId'> & {
110
+ readonly sessionId: Readonly<IBrowserFlexSessionId>;
111
+ })
112
+ | (Omit<Readonly<IBrowserMcpCapabilityBinding>, 'sessionId'> & {
113
+ readonly sessionId: Readonly<IBrowserOpenCodeSessionId>;
114
+ });
115
+
116
+ export type TBrowserRuntimeOperationIdentity = TReadonlyBrowserCapabilityAuthorizationRequest & {
117
+ readonly operationId: string;
118
+ readonly capabilityId: string;
119
+ readonly leaseId: string;
120
+ readonly action: string;
121
+ readonly startedAt: number;
122
+ };
123
+
124
+ export type TBrowserRuntimeAuditEvent = TBrowserRuntimeOperationIdentity & {
125
+ readonly phase: 'completed' | 'failed';
126
+ readonly finishedAt: number;
127
+ readonly errorCode?: string;
116
128
  };
117
129
 
118
130
  export interface ILiveBrowserSessionLike {
@@ -207,6 +219,10 @@ export interface IBrowserRuntimeOptions {
207
219
  request: TBrowserCapabilityAuthorizationRequest,
208
220
  signal: AbortSignal,
209
221
  ): Promise<boolean> | boolean;
222
+ beforeOperation?(
223
+ operation: TBrowserRuntimeOperationIdentity,
224
+ signal: AbortSignal,
225
+ ): Promise<void> | void;
210
226
  audit?(event: TBrowserRuntimeAuditEvent, signal: AbortSignal): Promise<void> | void;
211
227
  maxResources?: number;
212
228
  maxResourcesPerProject?: number;
@@ -218,6 +234,7 @@ export interface IBrowserRuntimeOptions {
218
234
  capabilityDefaultTtlMs?: number;
219
235
  capabilityMaximumTtlMs?: number;
220
236
  authorizationTimeoutMs?: number;
237
+ beforeOperationTimeoutMs?: number;
221
238
  maxTabsPerResource?: number;
222
239
  operationTimeoutMs?: number;
223
240
  quiescenceTimeoutMs?: number;