import { createHash, randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; import type { AgentBrowserBounds, AgentBrowserCdpEventPage, AgentBrowserCdpResult, AgentBrowserErrorCode, AgentBrowserPayloadChunk, AgentBrowserPayloadRef, AgentBrowserSnapshot, AgentBrowserState, } from '../../shared/agent-browser'; import type { AgentBrowserAdapter, AgentBrowserViewPort, PortListener, } from './adapter'; import { AgentBrowserCdpGuard } from './cdp-guard'; import { AgentBrowserEventBuffer } from './event-buffer'; import { AgentBrowserFault } from './fault'; import { AgentBrowserPayloadStore } from './payload-store'; const CDP_PROTOCOL_VERSION = '1.3'; const INLINE_RESULT_BYTES = 64 * 1024; const MAX_WAIT_MS = 5_000; const DEFAULT_CDP_TIMEOUT_MS = 10_000; const MAX_CDP_TIMEOUT_MS = 30_000; const OPEN_TIMEOUT_MS = 30_000; const RENDERER_PRIME_URL = 'about:blank'; const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const; const STREAM_ISSUING_METHODS = new Set([ 'Fetch.takeResponseBodyAsStream', 'Network.loadNetworkResource', 'Page.printToPDF', 'Tracing.tracingComplete', ]); interface BrowserRecord { browserId: string; projectId: string; projectPath: string; partition: string; view: AgentBrowserViewPort; state: AgentBrowserState; generation: number; url: string; title: string; visible: boolean; bounds: AgentBrowserBounds | null; error?: { code: AgentBrowserErrorCode; message: string; }; eventBuffer: AgentBrowserEventBuffer; childSessions: Set; ioHandles: Set; webContentsListeners: Array<{ event: string; listener: PortListener }>; debuggerListeners: Array<{ event: 'message' | 'detach'; listener: PortListener; }>; interruptWaiters: Set<(fault: AgentBrowserFault) => void>; } export interface AgentBrowserOpenInput { projectId: string; projectPath: string; url: string; bounds?: AgentBrowserBounds; visible?: boolean; } export interface AgentBrowserPresentInput { projectPath: string; visible: boolean; bounds?: AgentBrowserBounds; } export interface AgentBrowserNavigateInput { projectPath: string; action: 'url' | 'back' | 'forward' | 'reload'; url?: string; } export interface AgentBrowserSendCdpInput { projectPath: string; method: string; params?: Record; sessionRef?: string; timeoutMs?: number; } export interface AgentBrowserReadEventsInput { projectPath: string; after?: number; methods?: string[]; limit?: number; waitMs?: number; } export interface AgentBrowserReadPayloadInput { projectPath: string; handle: string; offset?: number; maxBytes?: number; } export interface AgentBrowserModuleOptions { payloadStore?: AgentBrowserPayloadStore; cdpGuard?: AgentBrowserCdpGuard; } export function agentBrowserPartition(projectPath: string): string { const normalized = normalizePath(projectPath); const key = createHash('sha256').update(normalized).digest('hex').slice(0, 32); return `persist:niancode-agent-browser:${key}`; } export class AgentBrowserModule { private readonly payloadStore: AgentBrowserPayloadStore; private readonly cdpGuard: AgentBrowserCdpGuard; private readonly eventWaiters = new Set<() => void>(); private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>(); private record: BrowserRecord | null = null; private commandTail: Promise = Promise.resolve(); private lifecycleBarrier: Promise = Promise.resolve(); private queueEpoch = 0; private generation = 0; private disposed = false; constructor( private readonly adapter: AgentBrowserAdapter, options: AgentBrowserModuleOptions = {}, ) { this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore(); this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard(); } async getSnapshot(projectPath?: string): Promise { if (!this.record) return this.closedSnapshot(); if (projectPath) this.assertProject(this.record, projectPath); return this.snapshot(this.record); } open(input: AgentBrowserOpenInput): Promise { return this.serialize(async () => { this.assertAvailable(); const projectPath = normalizeRequiredPath(input.projectPath); const targetUrl = normalizeUrl(input.url); const bounds = input.bounds ? normalizeBounds(input.bounds) : null; if (this.record && !samePath(this.record.projectPath, projectPath)) { await this.closeInternal(); } if (this.record) { const record = this.record; if (record.state === 'suspended_devtools') { throw new AgentBrowserFault( 'DEVTOOLS_CONFLICT', '请先关闭当前页面的原生 DevTools。', true, record.generation, ); } if (record.state !== 'attached') { await this.closeInternal(); } } if (this.record) { const record = this.record; if (bounds) { this.applyPresentation(record, input.visible ?? true, bounds); } if (record.url !== targetUrl || record.error) { await this.navigateTo(record, targetUrl); } return this.snapshot(record); } const view = this.adapter.createView(agentBrowserPartition(projectPath)); const record: BrowserRecord = { browserId: randomUUID(), projectId: input.projectId, projectPath, partition: agentBrowserPartition(projectPath), view, state: 'opening', generation: ++this.generation, url: targetUrl, title: '', visible: Boolean(bounds) && (input.visible ?? true), bounds, eventBuffer: new AgentBrowserEventBuffer(), childSessions: new Set(), ioHandles: new Set(), webContentsListeners: [], debuggerListeners: [], interruptWaiters: new Set(), }; this.record = record; this.registerListeners(record); record.view.webContents.denyWindowOpen(); if (bounds) record.view.setBounds(bounds); record.view.setVisible(record.visible); this.adapter.mount(record.view); try { await this.runWhileActive( record, record.view.webContents.loadURL(RENDERER_PRIME_URL), OPEN_TIMEOUT_MS, ); await this.runWhileActive( record, this.attachDebugger(record, false), OPEN_TIMEOUT_MS, ); } catch (error) { await this.closeInternal(record); throw toFault( error, 'ATTACH_FAILED', '无法连接开发浏览器调试协议。', true, record.generation, ); } try { await this.loadPageUntilReady(record, targetUrl); try { record.view.webContents.navigationHistory.clear(); } catch { // The first real page remains usable if Chromium history is unavailable. } } catch (error) { await this.closeInternal(record); throw toFault( error, 'CDP_PROTOCOL_ERROR', '页面加载失败。', true, record.generation, ); } this.refreshMetadata(record); return this.snapshot(record); }); } present(input: AgentBrowserPresentInput): Promise { return this.serialize(async () => { const record = this.requireRecord(input.projectPath); if (input.visible && !input.bounds) { throw new AgentBrowserFault( 'VIEWPORT_NOT_READY', '显示开发浏览器时必须提供当前显示区域。', true, record.generation, ); } const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds; this.applyPresentation(record, input.visible, bounds); return this.snapshot(record); }); } navigate(input: AgentBrowserNavigateInput): Promise { return this.serialize(async () => { const record = this.requireAttached(input.projectPath); const navigation = record.view.webContents.navigationHistory; if (input.action === 'url') { if (!input.url) { throw new AgentBrowserFault('INVALID_URL', '缺少网页地址。', false); } await this.navigateTo(record, normalizeUrl(input.url)); } else if (input.action === 'back') { if (navigation.canGoBack()) navigation.goBack(); } else if (input.action === 'forward') { if (navigation.canGoForward()) navigation.goForward(); } else { record.view.webContents.reload(); } this.refreshMetadata(record); return this.snapshot(record); }); } sendCdp(input: AgentBrowserSendCdpInput): Promise { const timeoutMs = normalizeTimeout(input.timeoutMs); return this.serializeWithTimeout(async () => { const record = this.requireAttached(input.projectPath); const method = input.method.trim(); if (!method) { throw new AgentBrowserFault('INVALID_REQUEST', 'CDP method 不能为空。', false); } this.cdpGuard.assertAllowed( method, input.params, input.sessionRef, record.childSessions, record.ioHandles, ); let result: unknown; try { result = await record.view.webContents.debugger.sendCommand( method, input.params, input.sessionRef, ); } catch (error) { throw toFault( error, 'CDP_PROTOCOL_ERROR', `CDP method ${method} 执行失败。`, true, record.generation, ); } if ( record !== this.record || record.state !== 'attached' || record.generation !== this.generation ) { throw new AgentBrowserFault( 'CLOSED', 'CDP 命令完成前开发浏览器已关闭或切换。', true, record.generation, 'unknown', ); } this.captureIoHandles(method, result, record); if (method === 'IO.close' && typeof input.params?.handle === 'string') { record.ioHandles.delete(input.params.handle); } if (method === 'IO.read' && isRecord(result) && result.eof === true && typeof input.params?.handle === 'string') { record.ioHandles.delete(input.params.handle); } return this.encodeResult(result); }, timeoutMs); } async readEvents(input: AgentBrowserReadEventsInput): Promise { let record = this.requirePresented(input.projectPath); const after = normalizeNonNegativeInteger(input.after, 0, 'after'); const limit = normalizeIntegerRange(input.limit, 100, 1, 200, 'limit'); const waitMs = normalizeIntegerRange(input.waitMs, 0, 0, MAX_WAIT_MS, 'waitMs'); if (after > record.eventBuffer.cursor) { throw new AgentBrowserFault( 'INVALID_REQUEST', '事件游标超出当前浏览器事件范围。', false, record.generation, ); } let page = record.eventBuffer.read(after, input.methods, limit); if ( page.events.length > 0 || page.gap || waitMs === 0 || page.nextCursor < record.eventBuffer.cursor ) { return page; } const cursorBeforeWait = record.eventBuffer.cursor; await this.waitForEvent(waitMs, () => { const current = this.record; return !current || current !== record || current.eventBuffer.cursor !== cursorBeforeWait; }); record = this.requirePresented(input.projectPath); page = record.eventBuffer.read(page.nextCursor, input.methods, limit); return page; } async readPayload(input: AgentBrowserReadPayloadInput): Promise { this.requirePresented(input.projectPath); return this.payloadStore.read(input.handle, input.offset, input.maxBytes); } async close(projectPath?: string): Promise { if (this.record && projectPath) this.assertProject(this.record, projectPath); this.preemptCommands('开发浏览器已关闭。'); await this.closeInternal(); return this.closedSnapshot(); } async resetProfile(projectPath: string): Promise { const normalized = normalizeRequiredPath(projectPath); if (this.record) this.assertProject(this.record, normalized); this.preemptCommands('开发浏览器数据正在重置。'); const previousBarrier = this.lifecycleBarrier; let releaseBarrier: (() => void) | undefined; const barrier = new Promise((resolvePromise) => { releaseBarrier = resolvePromise; }); this.lifecycleBarrier = barrier; try { await previousBarrier; await this.closeInternal(); await this.adapter.resetPartition(agentBrowserPartition(normalized)); return this.closedSnapshot(); } finally { releaseBarrier?.(); } } async dispose(): Promise { if (this.disposed) return; this.disposed = true; this.preemptCommands('开发浏览器模块已关闭。'); await this.closeInternal(); this.payloadStore.clear(); } private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise { if (record !== this.record || record.view.webContents.isDestroyed()) { throw new AgentBrowserFault( 'TARGET_GONE', '开发浏览器页面已关闭。', true, record.generation, ); } const port = record.view.webContents.debugger; const needsAttach = !port.isAttached(); if (reattach && needsAttach) { record.generation = ++this.generation; record.childSessions.clear(); record.ioHandles.clear(); } record.state = 'attaching'; record.error = undefined; if (needsAttach) port.attach(CDP_PROTOCOL_VERSION); for (const method of ENABLED_DOMAINS) { await port.sendCommand(method); } await port.sendCommand('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }); if (record !== this.record || record.view.webContents.isDestroyed()) { throw new AgentBrowserFault( 'CLOSED', '开发浏览器调试器连接完成前页面已关闭或切换。', true, record.generation, 'unknown', ); } record.state = 'attached'; record.error = undefined; } private registerListeners(record: BrowserRecord): void { const contents = record.view.webContents; const onDebuggerMessage: PortListener = ( _event, methodValue, params, sessionRefValue, ) => { if (record !== this.record || typeof methodValue !== 'string') return; const sessionRef = typeof sessionRefValue === 'string' ? sessionRefValue : undefined; this.updateTargetScope(record, methodValue, params); this.captureIoHandles(methodValue, params, record); this.appendEvent(record, methodValue, params, sessionRef); }; const onDebuggerDetach: PortListener = (_event, reasonValue) => { if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { return; } record.eventBuffer.markGap('debugger-detached'); record.childSessions.clear(); record.ioHandles.clear(); const devToolsOpen = !contents.isDestroyed() && contents.isDevToolsOpened(); record.state = devToolsOpen ? 'suspended_devtools' : 'detached_fault'; record.error = { code: devToolsOpen ? 'DEVTOOLS_CONFLICT' : 'DEBUGGER_BUSY', message: devToolsOpen ? '原生 DevTools 正在使用当前页面调试器。' : `开发浏览器调试器已断开:${String(reasonValue ?? 'unknown')}`, }; this.notifyEventWaiters(); }; this.addDebuggerListener(record, 'message', onDebuggerMessage); this.addDebuggerListener(record, 'detach', onDebuggerDetach); this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => { if (typeof urlValue === 'string') record.url = urlValue; this.refreshMetadata(record); }); this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue) => { if (typeof urlValue === 'string') record.url = urlValue; this.refreshMetadata(record); }); this.addWebContentsListener(record, 'page-title-updated', (_event, titleValue) => { if (typeof titleValue === 'string') record.title = titleValue; }); this.addWebContentsListener(record, 'devtools-opened', () => { if (record !== this.record || record.state === 'closing') return; record.state = 'suspended_devtools'; record.error = { code: 'DEVTOOLS_CONFLICT', message: '原生 DevTools 已打开,智能体调试暂时暂停。', }; }); this.addWebContentsListener(record, 'devtools-closed', () => { if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { return; } void this.serialize(async () => { if (record !== this.record || record.view.webContents.isDestroyed()) return; try { await this.runWhileActive( record, this.attachDebugger(record, true), OPEN_TIMEOUT_MS, ); } catch (error) { if (record !== this.record || record.view.webContents.isDestroyed()) return; record.state = 'detached_fault'; const fault = toFault( error, 'ATTACH_FAILED', '关闭 DevTools 后无法恢复智能体调试。', true, record.generation, ); record.error = { code: fault.code, message: fault.message }; } }).catch(() => undefined); }); this.addWebContentsListener(record, 'render-process-gone', () => { if (record !== this.record || record.state === 'closing') return; record.state = 'crashed'; record.error = { code: 'RENDERER_CRASHED', message: '开发浏览器页面进程已退出。', }; record.eventBuffer.markGap('view-recreated'); record.childSessions.clear(); record.ioHandles.clear(); this.notifyEventWaiters(); }); this.addWebContentsListener(record, 'destroyed', () => { if (record !== this.record || record.state === 'closing') return; record.state = 'crashed'; record.error = { code: 'TARGET_GONE', message: '开发浏览器页面已关闭。', }; record.eventBuffer.markGap('view-recreated'); this.notifyEventWaiters(); }); } private appendEvent( record: BrowserRecord, method: string, params: unknown, sessionRef?: string, ): void { const base = { generation: record.generation, timestamp: Date.now(), method, ...(sessionRef ? { sessionRef } : {}), }; let eventParams = params; let payload: AgentBrowserPayloadRef | undefined; try { const bytes = Buffer.byteLength(JSON.stringify({ ...base, params })); if (bytes > INLINE_RESULT_BYTES) { payload = this.payloadStore.putJson(params); eventParams = undefined; } } catch { eventParams = { omitted: true, reason: 'payload-too-large-or-invalid' }; } record.eventBuffer.push({ ...base, ...(eventParams === undefined ? {} : { params: eventParams }), ...(payload ? { payload } : {}), }); this.notifyEventWaiters(); } private updateTargetScope(record: BrowserRecord, method: string, params: unknown): void { if (!isRecord(params)) return; if (method === 'Target.attachedToTarget' && typeof params.sessionId === 'string') { record.childSessions.add(params.sessionId); } else if ( method === 'Target.detachedFromTarget' && typeof params.sessionId === 'string' ) { record.childSessions.delete(params.sessionId); } } private captureIoHandles(method: string, value: unknown, record: BrowserRecord): void { if (!STREAM_ISSUING_METHODS.has(method)) return; collectStringFields(value, 'stream', record.ioHandles); } private encodeResult(result: unknown): AgentBrowserCdpResult { let byteLength: number; try { byteLength = Buffer.byteLength(JSON.stringify(result) ?? 'null'); } catch { throw new AgentBrowserFault( 'CDP_PROTOCOL_ERROR', 'CDP 返回了无法序列化的数据。', false, ); } return byteLength > INLINE_RESULT_BYTES ? this.payloadStore.putJson(result) : { kind: 'inline', value: result }; } private applyPresentation( record: BrowserRecord, visible: boolean, bounds: AgentBrowserBounds | null, ): void { if (bounds) { record.view.setBounds(bounds); record.bounds = bounds; } record.view.setVisible(visible); record.visible = visible; } private async navigateTo(record: BrowserRecord, targetUrl: string): Promise { try { await this.loadPageUntilReady(record, targetUrl); record.url = targetUrl; record.error = undefined; } catch (error) { throw toFault( error, 'CDP_PROTOCOL_ERROR', '页面导航失败。', true, record.generation, ); } } private async loadPageUntilReady( record: BrowserRecord, targetUrl: string, ): Promise { const contents = record.view.webContents; await new Promise((resolvePromise, rejectPromise) => { let settled = false; let timer: ReturnType | undefined; let interrupt: ((fault: AgentBrowserFault) => void) | undefined; const cleanup = () => { this.removeWebContentsListener(record, 'dom-ready', onDomReady); this.removeWebContentsListener(record, 'did-fail-load', onDidFailLoad); if (timer) clearTimeout(timer); if (interrupt) record.interruptWaiters.delete(interrupt); }; const succeed = () => { if (settled) return; settled = true; cleanup(); resolvePromise(); }; const fail = (error: unknown) => { if (settled) return; settled = true; cleanup(); rejectPromise(error); }; const onDomReady: PortListener = () => { succeed(); }; const onDidFailLoad: PortListener = ( _event, errorCodeValue, errorDescriptionValue, _validatedUrl, isMainFrameValue, ) => { if (isMainFrameValue === false) return; if ( errorCodeValue === -3 || errorDescriptionValue === 'ERR_ABORTED' ) { return; } fail(new Error( typeof errorDescriptionValue === 'string' && errorDescriptionValue ? errorDescriptionValue : '页面加载失败。', )); }; interrupt = fail; this.addWebContentsListener(record, 'dom-ready', onDomReady); this.addWebContentsListener(record, 'did-fail-load', onDidFailLoad); record.interruptWaiters.add(interrupt); timer = setTimeout(() => { fail(new AgentBrowserFault( 'CDP_TIMEOUT', `开发浏览器操作在 ${OPEN_TIMEOUT_MS}ms 内未完成。`, true, record.generation, 'unknown', )); }, OPEN_TIMEOUT_MS); try { contents.loadURL(targetUrl).then(succeed, (error: unknown) => { if (!isNavigationAbort(error)) fail(error); }); } catch (error) { fail(error); } }); } private refreshMetadata(record: BrowserRecord): void { if (record.view.webContents.isDestroyed()) return; record.url = safeUrl(record.view, record.url); try { record.title = record.view.webContents.getTitle(); } catch { // Metadata is best effort while a navigation is committing. } } private snapshot(record: BrowserRecord): AgentBrowserSnapshot { this.refreshMetadata(record); const contents = record.view.webContents; let canGoBack = false; let canGoForward = false; if (!contents.isDestroyed()) { try { canGoBack = contents.navigationHistory.canGoBack(); canGoForward = contents.navigationHistory.canGoForward(); } catch { // Navigation state can disappear while the renderer is crashing. } } return { browserId: record.browserId, projectId: record.projectId, projectPath: record.projectPath, state: record.state, generation: record.generation, url: record.url, title: record.title, visible: record.visible, bounds: record.bounds, canGoBack, canGoForward, eventCursor: record.eventBuffer.cursor, ...(record.error ? { error: record.error } : {}), }; } private closedSnapshot(): AgentBrowserSnapshot { return { browserId: null, projectId: null, projectPath: null, state: 'closed', generation: this.generation, url: '', title: '', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0, }; } private requireRecord(projectPath: string): BrowserRecord { this.assertAvailable(); const record = this.record; if (!record) { throw new AgentBrowserFault('BROWSER_NOT_OPEN', '开发浏览器尚未打开。', true); } this.assertProject(record, projectPath); return record; } private requireAttached(projectPath: string): BrowserRecord { const record = this.requirePresented(projectPath); if (record.state === 'suspended_devtools') { throw new AgentBrowserFault( 'DEVTOOLS_CONFLICT', '请先关闭当前页面的原生 DevTools。', true, record.generation, ); } if (record.state === 'crashed') { throw new AgentBrowserFault( 'RENDERER_CRASHED', '开发浏览器页面进程已退出。', true, record.generation, ); } if (record.state !== 'attached' || !record.view.webContents.debugger.isAttached()) { throw new AgentBrowserFault( 'DEBUGGER_BUSY', '开发浏览器调试器尚未就绪。', true, record.generation, ); } return record; } private requirePresented(projectPath: string): BrowserRecord { const record = this.requireRecord(projectPath); if (!record.visible || !record.bounds) { throw new AgentBrowserFault( 'VIEWPORT_NOT_READY', '开发浏览器已收起,智能体调试已暂停。', true, record.generation, ); } return record; } private assertProject(record: BrowserRecord, projectPath: string): void { if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) { throw new AgentBrowserFault( 'PROJECT_MISMATCH', '智能体只能访问当前项目的开发浏览器。', false, record.generation, ); } } private assertAvailable(): void { if (this.disposed) { throw new AgentBrowserFault('CLOSED', '开发浏览器模块已关闭。', false); } } private async closeInternal(expected?: BrowserRecord): Promise { const record = expected ?? this.record; if (!record) { this.notifyEventWaiters(); return; } if (expected && this.record !== expected) return; record.state = 'closing'; this.removeListeners(record); this.record = null; const interrupted = new AgentBrowserFault( 'CLOSED', '开发浏览器已关闭。', true, record.generation, 'unknown', ); for (const interrupt of record.interruptWaiters) interrupt(interrupted); record.interruptWaiters.clear(); try { if (record.view.webContents.debugger.isAttached()) { record.view.webContents.debugger.detach(); } } catch { // The renderer may already be gone. } try { record.view.setVisible(false); } catch { // Native view teardown is best effort during app shutdown. } this.adapter.unmount(record.view); this.adapter.destroy(record.view); record.childSessions.clear(); record.ioHandles.clear(); record.eventBuffer.clear(); this.payloadStore.clear(); this.notifyEventWaiters(); } private addWebContentsListener( record: BrowserRecord, event: string, listener: PortListener, ): void { record.view.webContents.on(event, listener); record.webContentsListeners.push({ event, listener }); } private addDebuggerListener( record: BrowserRecord, event: 'message' | 'detach', listener: PortListener, ): void { record.view.webContents.debugger.on(event, listener); record.debuggerListeners.push({ event, listener }); } private removeWebContentsListener( record: BrowserRecord, event: string, listener: PortListener, ): void { record.view.webContents.removeListener(event, listener); const index = record.webContentsListeners.findIndex( (entry) => entry.event === event && entry.listener === listener, ); if (index >= 0) record.webContentsListeners.splice(index, 1); } private removeListeners(record: BrowserRecord): void { for (const { event, listener } of record.webContentsListeners) { record.view.webContents.removeListener(event, listener); } for (const { event, listener } of record.debuggerListeners) { record.view.webContents.debugger.removeListener(event, listener); } record.webContentsListeners.length = 0; record.debuggerListeners.length = 0; } private serialize(operation: () => Promise): Promise { const epoch = this.queueEpoch; const barrier = this.lifecycleBarrier; let resolveCaller: (value: T) => void; let rejectCaller: (reason: unknown) => void; let callerSettled = false; const caller = new Promise((resolvePromise, rejectPromise) => { resolveCaller = resolvePromise; rejectCaller = rejectPromise; }); const cancelCaller = (fault: AgentBrowserFault) => { if (callerSettled) return; callerSettled = true; rejectCaller(fault); }; this.commandCancellers.add(cancelCaller); const execution = this.commandTail.then(async () => { try { await barrier; if (callerSettled) return; if (epoch !== this.queueEpoch) { throw new AgentBrowserFault( 'CLOSED', '开发浏览器操作已被新的生命周期取代。', true, this.record?.generation, 'unknown', ); } const value = await operation(); if (!callerSettled) { callerSettled = true; this.commandCancellers.delete(cancelCaller); resolveCaller(value); } } catch (error) { if (!callerSettled) { callerSettled = true; this.commandCancellers.delete(cancelCaller); rejectCaller(error); } } }); this.commandTail = execution.then( () => undefined, () => undefined, ); return caller; } private serializeWithTimeout( operation: () => Promise, timeoutMs: number, ): Promise { const epoch = this.queueEpoch; const barrier = this.lifecycleBarrier; let resolveCaller: (value: T) => void; let rejectCaller: (reason: unknown) => void; let callerSettled = false; const generation = this.record?.generation; let timer: ReturnType | undefined; const caller = new Promise((resolvePromise, rejectPromise) => { resolveCaller = resolvePromise; rejectCaller = rejectPromise; }); const cancelCaller = (fault: AgentBrowserFault) => { if (callerSettled) return; callerSettled = true; if (timer) clearTimeout(timer); rejectCaller(fault); }; this.commandCancellers.add(cancelCaller); timer = setTimeout(() => { if (callerSettled) return; callerSettled = true; this.commandCancellers.delete(cancelCaller); rejectCaller(new AgentBrowserFault( 'CDP_TIMEOUT', `CDP 命令在 ${timeoutMs}ms 内未完成,执行结果未知。`, true, generation, 'unknown', )); this.preemptCommands('CDP 命令超时,开发浏览器已关闭。'); void this.closeInternal().catch(() => undefined); }, timeoutMs); const execution = this.commandTail.then(async () => { await barrier; if (callerSettled || epoch !== this.queueEpoch) return; try { const value = await operation(); if (!callerSettled) { callerSettled = true; this.commandCancellers.delete(cancelCaller); if (timer) clearTimeout(timer); resolveCaller(value); } } catch (error) { if (!callerSettled) { callerSettled = true; this.commandCancellers.delete(cancelCaller); if (timer) clearTimeout(timer); rejectCaller(error); } } }); this.commandTail = execution.then( () => undefined, () => undefined, ); return caller; } private preemptCommands(message: string): void { this.queueEpoch += 1; this.commandTail = Promise.resolve(); const fault = new AgentBrowserFault( 'CLOSED', message, true, this.record?.generation, 'unknown', ); const cancellers = [...this.commandCancellers]; this.commandCancellers.clear(); for (const cancel of cancellers) cancel(fault); } private async runWhileActive( record: BrowserRecord, operation: Promise, timeoutMs: number, ): Promise { if (record !== this.record) { throw new AgentBrowserFault( 'CLOSED', '开发浏览器已关闭或切换。', true, record.generation, 'unknown', ); } let settled = false; let timer: ReturnType | undefined; let interrupt: ((fault: AgentBrowserFault) => void) | undefined; const result = new Promise((resolvePromise, rejectPromise) => { interrupt = (fault) => { if (settled) return; settled = true; rejectPromise(fault); }; record.interruptWaiters.add(interrupt); timer = setTimeout(() => { if (settled) return; settled = true; rejectPromise(new AgentBrowserFault( 'CDP_TIMEOUT', `开发浏览器操作在 ${timeoutMs}ms 内未完成。`, true, record.generation, 'unknown', )); }, timeoutMs); operation.then( (value) => { if (settled) return; settled = true; if (record !== this.record) { rejectPromise(new AgentBrowserFault( 'CLOSED', '开发浏览器操作完成前页面已关闭或切换。', true, record.generation, 'unknown', )); return; } resolvePromise(value); }, (error: unknown) => { if (settled) return; settled = true; rejectPromise(error); }, ); }); try { return await result; } finally { if (timer) clearTimeout(timer); if (interrupt) record.interruptWaiters.delete(interrupt); } } private async waitForEvent(waitMs: number, changed: () => boolean): Promise { if (changed()) return; let wake: (() => void) | undefined; const eventPromise = new Promise((resolvePromise) => { wake = resolvePromise; this.eventWaiters.add(resolvePromise); }); if (changed()) wake?.(); let timer: ReturnType | undefined; try { await Promise.race([ eventPromise, new Promise((resolvePromise) => { timer = setTimeout(resolvePromise, waitMs); }), ]); } finally { if (timer) clearTimeout(timer); if (wake) this.eventWaiters.delete(wake); } } private notifyEventWaiters(): void { const waiters = [...this.eventWaiters]; this.eventWaiters.clear(); for (const wake of waiters) wake(); } } function normalizeRequiredPath(projectPath: string): string { if (!projectPath.trim()) { throw new AgentBrowserFault('INVALID_REQUEST', '项目目录不能为空。', false); } return resolve(projectPath); } function normalizePath(projectPath: string): string { const normalized = resolve(projectPath); return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized; } function samePath(left: string, right: string): boolean { return normalizePath(left) === normalizePath(right); } function normalizeUrl(value: string): string { let url: URL; try { url = new URL(value); } catch { throw new AgentBrowserFault('INVALID_URL', '网页地址格式无效。', false); } if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new AgentBrowserFault( 'INVALID_URL', '开发浏览器只允许打开 HTTP 或 HTTPS 地址。', false, ); } return url.toString(); } function normalizeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds { const normalized = { x: Math.trunc(bounds.x), y: Math.trunc(bounds.y), width: Math.trunc(bounds.width), height: Math.trunc(bounds.height), }; if ( !Object.values(normalized).every(Number.isFinite) || normalized.width < 1 || normalized.height < 1 ) { throw new AgentBrowserFault( 'VIEWPORT_NOT_READY', '开发浏览器显示区域无效。', true, ); } return normalized; } function normalizeTimeout(value?: number): number { if (value === undefined) return DEFAULT_CDP_TIMEOUT_MS; if (!Number.isFinite(value) || value < 1) { throw new AgentBrowserFault('INVALID_REQUEST', 'CDP timeoutMs 无效。', false); } return Math.min(Math.trunc(value), MAX_CDP_TIMEOUT_MS); } function normalizeNonNegativeInteger( value: number | undefined, fallback: number, field: string, ): number { if (value === undefined) return fallback; if (!Number.isFinite(value) || value < 0) { throw new AgentBrowserFault('INVALID_REQUEST', `${field} 无效。`, false); } return Math.trunc(value); } function normalizeIntegerRange( value: number | undefined, fallback: number, min: number, max: number, field: string, ): number { const normalized = normalizeNonNegativeInteger(value, fallback, field); if (normalized < min) { throw new AgentBrowserFault('INVALID_REQUEST', `${field} 无效。`, false); } return Math.min(normalized, max); } function safeUrl(view: AgentBrowserViewPort, fallback: string): string { try { return view.webContents.getURL() || fallback; } catch { return fallback; } } function toFault( error: unknown, code: AgentBrowserErrorCode, fallbackMessage: string, retryable: boolean, generation?: number, ): AgentBrowserFault { if (error instanceof AgentBrowserFault) return error; return new AgentBrowserFault( code, errorMessage(error, fallbackMessage), retryable, generation, ); } function errorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message : fallback; } function isNavigationAbort(error: unknown): boolean { if (isRecord(error) && error.code === -3) return true; const message = error instanceof Error ? error.message : String(error ?? ''); return message.includes('ERR_ABORTED'); } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function collectStringFields(value: unknown, field: string, output: Set): void { if (Array.isArray(value)) { for (const item of value) collectStringFields(item, field, output); return; } if (!isRecord(value)) return; for (const [key, nested] of Object.entries(value)) { if (key === field && typeof nested === 'string') output.add(nested); else collectStringFields(nested, field, output); } }