import type * as plugins from './plugins.js'; import type { SmartDataAuthStore } from './classes.authstore.js'; import { ControllerResourceUnavailableError } from './classes.resourcecoordinator.js'; import { awaitWithAbortSignal } from './functions.abort.js'; import { hasBrowserSessionMember } from './functions.resourceattachments.js'; type TOperation = plugins.browserRuntime.TBrowserRuntimeOperationIdentity; type TStore = Pick; interface IBrowserOperationAuthorityOptions { store: TStore; isRetiring(operation: TOperation): boolean; assertHuman(operation: TOperation, signal: AbortSignal): void; authorizeFlex(operation: TOperation): boolean; authorizeMcp(operation: TOperation): boolean; } const semanticClassifications: ReadonlySet = new Set(['navigation', 'tab', 'agent-action']); /** Authorization runs after Runtime reserves execution order, never in a second queue. */ export class ControllerBrowserOperationAuthority { private readonly attachmentReads = new Map>; }>(); constructor(private readonly options: IBrowserOperationAuthorityOptions) {} public beforeOperation = async (operation: TOperation, signal: AbortSignal): Promise => { this.assertActive(operation, signal); // Event subscriptions also run during view activation/recovery. Their // lifecycle has its own authority checks and still verifies durable state. const liveHuman = operation.source === 'human' && operation.classification !== 'frame-stream'; if (liveHuman) { this.options.assertHuman(operation, signal); if (operation.classification === 'raw-input' || operation.action === 'getVideoStatistics') return; } if (operation.source === 'flex' && !this.options.authorizeFlex(operation)) { throw new Error('The Flex browser run is no longer active.'); } if (operation.source === 'mcp' && !this.options.authorizeMcp(operation)) { throw new Error('The MCP browser action is no longer active.'); } const semantic = semanticClassifications.has(operation.classification); const key = `${operation.projectId}\u0000${operation.browserResourceId}`; const cached = operation.source === 'human' || semantic ? undefined : this.attachmentReads.get(key); const now = Date.now(); let resource: Awaited>; if (cached && now - cached.readAt <= 250) resource = cached.resource; else { resource = await awaitWithAbortSignal(this.options.store.getResource(operation.projectId, operation.browserResourceId), signal); this.attachmentReads.set(key, { readAt: now, resource }); if (this.attachmentReads.size > 256) this.attachmentReads.delete(this.attachmentReads.keys().next().value!); } this.assertActive(operation, signal); if (!resource || resource.kind !== 'browser' || resource.lifecycle !== 'active' || resource.attachmentAuthorityId !== operation.attachmentAuthorityId || operation.role === 'agent' && (resource.pendingAttachment !== undefined || resource.attachmentRevision !== operation.attachmentRevision // The set must still contain the conversation the operation was authorized for; other // attached chats neither grant nor revoke this one. A terminal agent's own conversation is // a member through the terminal entry that carries it, exactly as the binding projects it. || operation.sessionId === undefined || !hasBrowserSessionMember(resource, operation.sessionId))) { throw new Error('The browser attachment is no longer current.'); } if (liveHuman) this.options.assertHuman(operation, signal); if (!semantic) return; await this.recordAudit(operation, 'attempted', signal); this.assertActive(operation, signal); if (liveHuman) this.options.assertHuman(operation, signal); }; public audit = async (event: plugins.browserRuntime.TBrowserRuntimeAuditEvent, signal: AbortSignal): Promise => { if (!semanticClassifications.has(event.classification)) return; await this.recordAudit(event, event.phase === 'completed' ? 'succeeded' : 'failed', signal); }; private assertActive(operation: TOperation, signal: AbortSignal): void { signal.throwIfAborted(); if (this.options.isRetiring(operation)) { throw new ControllerResourceUnavailableError('The browser resource is retiring.'); } } private async recordAudit(operation: TOperation, outcome: 'attempted' | 'succeeded' | 'failed', signal: AbortSignal): Promise { await awaitWithAbortSignal(this.options.store.recordAuditEvent({ type: 'resource.browser.operation', outcome, operationId: operation.operationId, peerId: operation.peerId, ...(operation.sessionId === undefined ? {} : { sessionId: operation.sessionId }), }), signal); } }