@modelprofile.com/browser-runtime 5.8.0 → 6.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.
@@ -24,7 +24,11 @@ export interface IBrowserResourceKey {
24
24
  export interface IBrowserAttachmentBinding {
25
25
  attachmentAuthorityId: string;
26
26
  attachmentRevision: number;
27
- sessionId: TQualifiedBrowserSessionId | null;
27
+ /**
28
+ * The set of sessions attached to the resource. Order is not significant and duplicates are
29
+ * rejected. An empty set is detached, and revision `0` requires an empty set.
30
+ */
31
+ sessionIds: TQualifiedBrowserSessionId[];
28
32
  }
29
33
  export interface ICreateBrowserResourceRequest {
30
34
  projectId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/browser-runtime",
3
- "version": "5.8.0",
3
+ "version": "6.0.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",
@@ -35,7 +35,7 @@
35
35
  "devDependencies": {
36
36
  "@git.zone/tsbuild": "4.4.3",
37
37
  "@git.zone/tsrun": "2.0.6",
38
- "@git.zone/tstest": "6.0.0",
38
+ "@git.zone/tstest": "6.1.1",
39
39
  "@types/node": "26.5.0"
40
40
  },
41
41
  "files": [
package/readme.md CHANGED
@@ -86,7 +86,7 @@ try {
86
86
  attachmentBinding: {
87
87
  attachmentAuthorityId: 'controller-attachment-1',
88
88
  attachmentRevision: 1,
89
- sessionId: { harnessId: 'opencode', nativeId: 'session-456' },
89
+ sessionIds: [{ harnessId: 'opencode', nativeId: 'session-456' }],
90
90
  },
91
91
  });
92
92
 
@@ -149,7 +149,7 @@ To downgrade, first stop the new runtime and verify that no process still uses a
149
149
 
150
150
  ## Attachment Fencing
151
151
 
152
- Attachment bindings are Controller-owned `{ attachmentAuthorityId, attachmentRevision, sessionId }` values. Any binding with `sessionId: null` is detached; revision `0` is the initial detached/no-agent-authority state. Reapplying the identical revision and binding is idempotent; lower revisions and conflicting equal revisions fail.
152
+ Attachment bindings are Controller-owned `{ attachmentAuthorityId, attachmentRevision, sessionIds }` values. `sessionIds` is the set of sessions attached to the resource, so one browser resource can serve several conversations at once. Order is not significant, duplicate members are rejected with `INVALID_INPUT`, and a set larger than 64 members is rejected with `QUOTA_EXCEEDED`. An empty set is detached; revision `0` is the initial detached/no-agent-authority state and requires an empty set. Reapplying the identical revision and set is idempotent, including in a different order; lower revisions and conflicting equal revisions fail.
153
153
 
154
154
  ```typescript
155
155
  await runtime.applyAttachmentBinding({
@@ -158,12 +158,14 @@ await runtime.applyAttachmentBinding({
158
158
  attachmentBinding: {
159
159
  attachmentAuthorityId: 'controller-attachment-1',
160
160
  attachmentRevision: 2,
161
- sessionId: null,
161
+ sessionIds: [],
162
162
  },
163
163
  });
164
164
  ```
165
165
 
166
- A newer binding fences agent admission, revokes older agent capabilities, and quiesces their active work. Human viewers retain their resource authority and subscriptions. The incarnation is preserved unless an operation cannot quiesce; that failure requires terminating the resource's browser process.
166
+ A newer binding fences only the sessions that left the set. Their leases and in-flight operations are aborted with `CAPABILITY_REVOKED` naming the session, their framed channels are closed, and their agent capabilities are revoked. Sessions that stay in the set keep their leases, framed channels and capabilities: nothing of theirs is aborted, closed or revoked, so once the rebind completes they carry on exactly as before and adding a session never interrupts the sessions already attached. While the rebind is still fencing, a session that is still in the set is never revoked: both issuing a new capability and operating an existing lease fail with `BUSY`, which is retryable, and the same call succeeds once the rebind settles. From the moment the rebind commits the new set, a session it removed is told it is gone — `CAPABILITY_REVOKED` — for the rest of that rebind, so the two outcomes are never confused. Before that commit the removal has not happened yet and may still not happen, so a session the queued rebind will remove also reports `BUSY` and should retry. Detaching with an empty set therefore releases every attached session. Human viewers retain their resource authority and subscriptions. The incarnation is preserved unless an operation cannot quiesce; that failure requires terminating the resource's browser process.
167
+
168
+ The revision fence is per session: a capability survives exactly as long as its session stays continuously attached; leaving and rejoining invalidates the old ones. Concretely, a capability is admitted while its session is a current member and the capability's `attachmentRevision` is at least the revision at which that session joined the set and no newer than the current revision. A session that leaves and later rejoins gets the newer join revision, so every capability it held before it left is rejected. Human capabilities carry no session and keep the exact-revision fence.
167
169
 
168
170
  Agent capabilities require the exact current non-detached qualified session. Human capabilities carry no session ID and may be issued while detached. Issuance validates the current attachment binding, but an issued human capability remains valid across attachment changes: hosts must authorize it against its project, resource, actor and peer rather than the resource's later agent assignment.
169
171
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/browser-runtime',
6
- version: '5.8.0',
6
+ version: '6.0.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
  }
@@ -292,6 +292,11 @@ export class BrowserRuntimeFramedServerPeer {
292
292
  return this.trustedChannelId;
293
293
  }
294
294
 
295
+ /** The single session this peer acts for. Used to fence only the sessions a rebind removed. */
296
+ public get sessionId(): IBrowserFlexSessionId {
297
+ return { ...this.trustedSessionId };
298
+ }
299
+
295
300
  /** @internal */
296
301
  public get activeCapabilityId(): string | undefined {
297
302
  return this.capabilityId ?? this.pendingCapabilityId;
@@ -61,6 +61,8 @@ import { BrowserRuntimeFramedServerPeer } from './classes.framed.js';
61
61
 
62
62
  // @push.rocks/smartpuppeteer LiveBrowserSession rejects screencast bounds above this area.
63
63
  const maxScreencastPixelArea = 8_294_400;
64
+ /** Upper bound on how many conversations may share one browser resource at once. */
65
+ const maximumAttachedSessions = 64;
64
66
 
65
67
  interface INormalizedScreencastOptions {
66
68
  quality: number;
@@ -259,7 +261,16 @@ interface IResourceSlot {
259
261
  permanentlyFenced: boolean;
260
262
  retirementPending: boolean;
261
263
  attachmentFenceCount: number;
264
+ /**
265
+ * Revision at which each currently attached session joined the set, keyed by session key. Only
266
+ * current members are present, so this doubles as the membership index. A member that stays
267
+ * across a rebind keeps its original join revision; a member that leaves and rejoins gets the
268
+ * new one, which is what invalidates its older capabilities.
269
+ */
270
+ sessionJoinRevisions: Map<string, number>;
262
271
  attachmentRetryBinding?: IBrowserAttachmentBinding;
272
+ /** Sessions that left the set in the rebind `attachmentRetryBinding` is retrying. */
273
+ attachmentRetryRemovedKeys?: Set<string>;
263
274
  session?: ILiveBrowserSessionLike;
264
275
  proxy?: BrowserEgressProxy;
265
276
  profileDirectory?: string;
@@ -743,6 +754,9 @@ export class BrowserRuntime {
743
754
  permanentlyFenced: false,
744
755
  retirementPending: false,
745
756
  attachmentFenceCount: 0,
757
+ sessionJoinRevisions: new Map(attachmentBinding.sessionIds.map((session) => (
758
+ [this.sessionKey(session), attachmentBinding.attachmentRevision]
759
+ ))),
746
760
  leases: new Map(),
747
761
  frameSubscriptions: new Map(),
748
762
  highestFrameSequence: 0,
@@ -4037,34 +4051,65 @@ export class BrowserRuntime {
4037
4051
  if (slot.attachmentRetryBinding && !this.bindingsEqual(binding, slot.attachmentRetryBinding)) {
4038
4052
  throw new BrowserRuntimeError('BUSY');
4039
4053
  }
4054
+ // Members that stay keep their original join revision, so their capabilities stay valid and
4055
+ // nothing of theirs is torn down. Only the sessions that left the set are fenced.
4056
+ const nextJoinRevisions = new Map<string, number>();
4057
+ for (const session of binding.sessionIds) {
4058
+ const key = this.sessionKey(session);
4059
+ nextJoinRevisions.set(
4060
+ key,
4061
+ slot.sessionJoinRevisions.get(key) ?? binding.attachmentRevision,
4062
+ );
4063
+ }
4064
+ const removedKeys = slot.attachmentRetryRemovedKeys ?? new Set(
4065
+ [...slot.sessionJoinRevisions.keys()].filter((key) => !nextJoinRevisions.has(key)),
4066
+ );
4067
+ const revokedFor = (key: string): BrowserRuntimeError => new BrowserRuntimeError(
4068
+ 'CAPABILITY_REVOKED',
4069
+ `attachment released session ${key}`,
4070
+ );
4040
4071
  const release = await slot.mutex.acquire();
4041
4072
  try {
4042
4073
  slot.attachmentBinding = binding;
4043
4074
  slot.attachmentRetryBinding = binding;
4075
+ slot.attachmentRetryRemovedKeys = removedKeys;
4076
+ slot.sessionJoinRevisions = nextJoinRevisions;
4044
4077
  for (const lease of slot.leases.values()) {
4045
- if (lease.role === 'agent') {
4046
- lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
4047
- }
4078
+ if (lease.role !== 'agent') continue;
4079
+ const key = this.leaseSessionKey(lease);
4080
+ if (key === undefined || !removedKeys.has(key)) continue;
4081
+ lease.controller.abort(revokedFor(key));
4048
4082
  }
4049
4083
  for (const operation of slot.operations) {
4050
- if (operation.lease.role === 'agent') operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
4084
+ if (operation.lease.role !== 'agent') continue;
4085
+ const key = this.leaseSessionKey(operation.lease);
4086
+ if (key === undefined || !removedKeys.has(key)) continue;
4087
+ operation.controller.abort(revokedFor(key));
4051
4088
  }
4052
4089
  } finally {
4053
4090
  release();
4054
4091
  }
4055
4092
  const peerResults = await Promise.allSettled(
4056
- this.framedPeersForResource(slot).map((peer) => peer.close()),
4093
+ this.framedPeersForResource(slot)
4094
+ .filter((peer) => removedKeys.has(this.sessionKey(peer.sessionId)))
4095
+ .map((peer) => peer.close()),
4057
4096
  );
4058
4097
  const capabilityResults = await Promise.allSettled(
4059
- this.capabilitiesForResource(slot).filter((record) => record.role === 'agent')
4060
- .map((record) => this.revokeCapabilityRecord(record)),
4098
+ this.capabilitiesForResource(slot).filter((record) => (
4099
+ record.role === 'agent'
4100
+ && record.sessionId !== undefined
4101
+ && removedKeys.has(this.sessionKey(record.sessionId))
4102
+ )).map((record) => this.revokeCapabilityRecord(record)),
4061
4103
  );
4062
4104
  const errors = [...peerResults, ...capabilityResults]
4063
4105
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
4064
4106
  .map((result) => result.reason);
4065
4107
  const finalRelease = await slot.mutex.acquire();
4066
4108
  try {
4067
- if (errors.length === 0) slot.attachmentRetryBinding = undefined;
4109
+ if (errors.length === 0) {
4110
+ slot.attachmentRetryBinding = undefined;
4111
+ slot.attachmentRetryRemovedKeys = undefined;
4112
+ }
4068
4113
  } finally {
4069
4114
  finalRelease();
4070
4115
  }
@@ -4073,10 +4118,9 @@ export class BrowserRuntime {
4073
4118
  }
4074
4119
 
4075
4120
  private attachmentBindingKey(binding: IBrowserAttachmentBinding): string {
4076
- const session = binding.sessionId
4077
- ? `${binding.sessionId.harnessId}:${binding.sessionId.nativeId}`
4078
- : 'detached';
4079
- return `${binding.attachmentRevision}:${binding.attachmentAuthorityId}:${session}`;
4121
+ return `${binding.attachmentRevision}:${binding.attachmentAuthorityId}:${
4122
+ this.sessionSetKey(binding.sessionIds)
4123
+ }`;
4080
4124
  }
4081
4125
 
4082
4126
  private tombstoneIndex(value: string, seed: number): number {
@@ -4141,7 +4185,7 @@ export class BrowserRuntime {
4141
4185
  if (Object.keys(record).some((key) => (
4142
4186
  key !== 'attachmentAuthorityId'
4143
4187
  && key !== 'attachmentRevision'
4144
- && key !== 'sessionId'
4188
+ && key !== 'sessionIds'
4145
4189
  ))) throw new BrowserRuntimeError('INVALID_INPUT');
4146
4190
  const attachmentRevision = validateInteger(
4147
4191
  record.attachmentRevision,
@@ -4149,10 +4193,21 @@ export class BrowserRuntime {
4149
4193
  0,
4150
4194
  Number.MAX_SAFE_INTEGER,
4151
4195
  );
4152
- const sessionId = record.sessionId === null
4153
- ? null
4154
- : this.validateQualifiedSessionId(record.sessionId);
4155
- if (attachmentRevision === 0 && sessionId !== null) {
4196
+ if (!Array.isArray(record.sessionIds)) throw new BrowserRuntimeError('INVALID_INPUT');
4197
+ if (record.sessionIds.length > maximumAttachedSessions) {
4198
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
4199
+ }
4200
+ const sessionIds: TQualifiedBrowserSessionId[] = [];
4201
+ const seen = new Set<string>();
4202
+ for (const entry of record.sessionIds) {
4203
+ const session = this.validateQualifiedSessionId(entry);
4204
+ const key = this.sessionKey(session);
4205
+ // The binding is a set. A repeated member is a caller defect, not something to collapse.
4206
+ if (seen.has(key)) throw new BrowserRuntimeError('INVALID_INPUT');
4207
+ seen.add(key);
4208
+ sessionIds.push(session);
4209
+ }
4210
+ if (attachmentRevision === 0 && sessionIds.length > 0) {
4156
4211
  throw new BrowserRuntimeError('INVALID_INPUT');
4157
4212
  }
4158
4213
  return {
@@ -4161,17 +4216,32 @@ export class BrowserRuntime {
4161
4216
  'attachmentAuthorityId',
4162
4217
  ),
4163
4218
  attachmentRevision,
4164
- sessionId,
4219
+ sessionIds,
4165
4220
  };
4166
4221
  }
4167
4222
 
4223
+ private sessionKey(session: TQualifiedBrowserSessionId): string {
4224
+ return `${session.harnessId}:${session.nativeId}`;
4225
+ }
4226
+
4227
+ private leaseSessionKey(lease: ILeaseRecord): string | undefined {
4228
+ const session = lease.capability.sessionId;
4229
+ return session ? this.sessionKey(session) : undefined;
4230
+ }
4231
+
4232
+ /** Order-insensitive: the same members in any order are the same binding. */
4233
+ private sessionSetKey(sessionIdsArg: readonly TQualifiedBrowserSessionId[]): string {
4234
+ if (sessionIdsArg.length === 0) return 'detached';
4235
+ return sessionIdsArg.map((session) => this.sessionKey(session)).sort().join(',');
4236
+ }
4237
+
4168
4238
  private bindingsEqual(
4169
4239
  left: IBrowserAttachmentBinding,
4170
4240
  right: IBrowserAttachmentBinding,
4171
4241
  ): boolean {
4172
4242
  return left.attachmentAuthorityId === right.attachmentAuthorityId
4173
4243
  && left.attachmentRevision === right.attachmentRevision
4174
- && this.sessionsEqual(left.sessionId, right.sessionId);
4244
+ && this.sessionSetKey(left.sessionIds) === this.sessionSetKey(right.sessionIds);
4175
4245
  }
4176
4246
 
4177
4247
  private sessionsEqual(
@@ -4211,9 +4281,7 @@ export class BrowserRuntime {
4211
4281
  registeredAt: slot.registeredAt,
4212
4282
  attachmentBinding: {
4213
4283
  ...slot.attachmentBinding,
4214
- sessionId: slot.attachmentBinding.sessionId
4215
- ? { ...slot.attachmentBinding.sessionId }
4216
- : null,
4284
+ sessionIds: slot.attachmentBinding.sessionIds.map((session) => ({ ...session })),
4217
4285
  },
4218
4286
  incarnationStatus: slot.permanentlyFenced || slot.attachmentRetryBinding
4219
4287
  ? 'fenced'
@@ -4233,19 +4301,33 @@ export class BrowserRuntime {
4233
4301
  >,
4234
4302
  ): void {
4235
4303
  const binding = slot.attachmentBinding;
4236
- if (
4237
- binding.attachmentAuthorityId !== expected.attachmentAuthorityId
4238
- || binding.attachmentRevision !== expected.attachmentRevision
4239
- ) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4304
+ if (binding.attachmentAuthorityId !== expected.attachmentAuthorityId) {
4305
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4306
+ }
4307
+ // A capability may never claim a revision the authority has not published yet, whatever its
4308
+ // role. For an agent the lower bound is its own join revision, not the current one.
4309
+ if (expected.attachmentRevision > binding.attachmentRevision) {
4310
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4311
+ }
4240
4312
  if (expected.role === 'agent') {
4313
+ if (!expected.sessionId) throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4314
+ // A capability survives exactly as long as its session stays continuously attached:
4315
+ // membership is the presence of a join revision, and continuity is the capability having
4316
+ // been minted at or after it. Leaving the set drops the entry; rejoining raises it.
4317
+ // This is decided before the in-progress check, so a session the rebind already removed is
4318
+ // told it is gone rather than told to retry.
4319
+ const joinedAtRevision = slot.sessionJoinRevisions.get(this.sessionKey(expected.sessionId));
4320
+ if (joinedAtRevision === undefined || expected.attachmentRevision < joinedAtRevision) {
4321
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4322
+ }
4241
4323
  if (slot.attachmentFenceCount > 0 || slot.attachmentRetryBinding) {
4242
4324
  throw new BrowserRuntimeError('BUSY');
4243
4325
  }
4244
- if (!binding.sessionId || !this.sessionsEqual(binding.sessionId, expected.sessionId)) {
4245
- throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4246
- }
4247
4326
  } else if (expected.sessionId !== undefined) {
4248
4327
  throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4328
+ } else if (binding.attachmentRevision !== expected.attachmentRevision) {
4329
+ // Human capabilities keep the exact-revision fence; they carry no session to be continuous.
4330
+ throw new BrowserRuntimeError('ATTACHMENT_CONFLICT');
4249
4331
  }
4250
4332
  }
4251
4333
 
@@ -4260,7 +4342,10 @@ export class BrowserRuntime {
4260
4342
  if (capability.role === 'human') return;
4261
4343
  try {
4262
4344
  this.assertCapabilityBinding(slot, capability);
4263
- } catch {
4345
+ } catch (error) {
4346
+ // An in-progress rebind is transient and says nothing about this capability's authority. A
4347
+ // session that is still in the set must be told to retry, not that it has been revoked.
4348
+ if (error instanceof BrowserRuntimeError && error.code === 'BUSY') throw error;
4264
4349
  throw new BrowserRuntimeError('CAPABILITY_REVOKED');
4265
4350
  }
4266
4351
  }
package/ts/interfaces.ts CHANGED
@@ -31,7 +31,11 @@ export interface IBrowserResourceKey {
31
31
  export interface IBrowserAttachmentBinding {
32
32
  attachmentAuthorityId: string;
33
33
  attachmentRevision: number;
34
- sessionId: TQualifiedBrowserSessionId | null;
34
+ /**
35
+ * The set of sessions attached to the resource. Order is not significant and duplicates are
36
+ * rejected. An empty set is detached, and revision `0` requires an empty set.
37
+ */
38
+ sessionIds: TQualifiedBrowserSessionId[];
35
39
  }
36
40
 
37
41
  export interface ICreateBrowserResourceRequest {