需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
1306 lines
40 KiB
TypeScript
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);
|
|
}
|
|
}
|