Files
makelore/electron/agent-browser/module.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

1306 lines
40 KiB
TypeScript

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<string>;
ioHandles: Set<string>;
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<string, unknown>;
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<void> = Promise.resolve();
private lifecycleBarrier: Promise<void> = 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<AgentBrowserSnapshot> {
if (!this.record) return this.closedSnapshot();
if (projectPath) this.assertProject(this.record, projectPath);
return this.snapshot(this.record);
}
open(input: AgentBrowserOpenInput): Promise<AgentBrowserSnapshot> {
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<AgentBrowserSnapshot> {
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<AgentBrowserSnapshot> {
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<AgentBrowserCdpResult> {
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<AgentBrowserCdpEventPage> {
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<AgentBrowserPayloadChunk> {
this.requirePresented(input.projectPath);
return this.payloadStore.read(input.handle, input.offset, input.maxBytes);
}
async close(projectPath?: string): Promise<AgentBrowserSnapshot> {
if (this.record && projectPath) this.assertProject(this.record, projectPath);
this.preemptCommands('开发浏览器已关闭。');
await this.closeInternal();
return this.closedSnapshot();
}
async resetProfile(projectPath: string): Promise<AgentBrowserSnapshot> {
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<void>((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<void> {
if (this.disposed) return;
this.disposed = true;
this.preemptCommands('开发浏览器模块已关闭。');
await this.closeInternal();
this.payloadStore.clear();
}
private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise<void> {
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<void> {
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<void> {
const contents = record.view.webContents;
await new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | 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<void> {
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<T>(operation: () => Promise<T>): Promise<T> {
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<T>((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<T>(
operation: () => Promise<T>,
timeoutMs: number,
): Promise<T> {
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<typeof setTimeout> | undefined;
const caller = new Promise<T>((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<T>(
record: BrowserRecord,
operation: Promise<T>,
timeoutMs: number,
): Promise<T> {
if (record !== this.record) {
throw new AgentBrowserFault(
'CLOSED',
'开发浏览器已关闭或切换。',
true,
record.generation,
'unknown',
);
}
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let interrupt: ((fault: AgentBrowserFault) => void) | undefined;
const result = new Promise<T>((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<void> {
if (changed()) return;
let wake: (() => void) | undefined;
const eventPromise = new Promise<void>((resolvePromise) => {
wake = resolvePromise;
this.eventWaiters.add(resolvePromise);
});
if (changed()) wake?.();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
eventPromise,
new Promise<void>((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<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
function collectStringFields(value: unknown, field: string, output: Set<string>): 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);
}
}