feat: 增加共享 Agent Browser 调试能力

需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
2026-07-31 14:53:36 +08:00
parent 80e8386fa6
commit e97a7ce9df
39 changed files with 6734 additions and 9 deletions

View File

@@ -0,0 +1,52 @@
import type { AgentBrowserBounds } from '../../shared/agent-browser';
export type PortListener = (...args: unknown[]) => void;
export interface AgentBrowserDebuggerPort {
attach(protocolVersion: string): void;
detach(): void;
isAttached(): boolean;
sendCommand(
method: string,
params?: Record<string, unknown>,
sessionRef?: string,
): Promise<unknown>;
on(event: 'message' | 'detach', listener: PortListener): void;
removeListener(event: 'message' | 'detach', listener: PortListener): void;
}
export interface AgentBrowserNavigationPort {
canGoBack(): boolean;
canGoForward(): boolean;
goBack(): void;
goForward(): void;
clear(): void;
}
export interface AgentBrowserWebContentsPort {
readonly debugger: AgentBrowserDebuggerPort;
readonly navigationHistory: AgentBrowserNavigationPort;
loadURL(url: string): Promise<void>;
getURL(): string;
getTitle(): string;
isDestroyed(): boolean;
isDevToolsOpened(): boolean;
reload(): void;
denyWindowOpen(): void;
on(event: string, listener: PortListener): void;
removeListener(event: string, listener: PortListener): void;
}
export interface AgentBrowserViewPort {
readonly webContents: AgentBrowserWebContentsPort;
setBounds(bounds: AgentBrowserBounds): void;
setVisible(visible: boolean): void;
}
export interface AgentBrowserAdapter {
createView(partition: string): AgentBrowserViewPort;
mount(view: AgentBrowserViewPort): void;
unmount(view: AgentBrowserViewPort): void;
destroy(view: AgentBrowserViewPort): void;
resetPartition(partition: string): Promise<void>;
}

View File

@@ -0,0 +1,108 @@
import { AgentBrowserFault } from './fault';
const BLOCKED_METHODS = new Set([
'DOM.getFileInfo',
'DOM.setFileInputFiles',
'Page.crash',
'Page.setDownloadBehavior',
'Security.handleCertificateError',
'Security.setDisableNetworkAccessForOrigins',
'Security.setIgnoreCertificateErrors',
'Security.setOverrideCertificateErrors',
]);
const BLOCKED_DOMAINS = [
'Cast.',
'DeviceAccess.',
'Extensions.',
'Tethering.',
] as const;
const URL_FIELDS_BY_METHOD = new Map<string, string[]>([
['Fetch.continueRequest', ['url']],
['Network.continueInterceptedRequest', ['url']],
['Network.loadNetworkResource', ['url']],
['Page.navigate', ['url']],
]);
function hasExternalTargetReference(params: Record<string, unknown> | undefined): boolean {
return Boolean(params && (
typeof params.targetId === 'string'
|| typeof params.browserContextId === 'string'
));
}
function isAllowedPageUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
export class AgentBrowserCdpGuard {
assertAllowed(
method: string,
params: Record<string, unknown> | undefined,
sessionRef: string | undefined,
childSessions: ReadonlySet<string>,
ioHandles: ReadonlySet<string>,
): void {
const blockedHostDomain = (
(method.startsWith('Browser.') && method !== 'Browser.getVersion')
|| method.startsWith('SystemInfo.')
|| method.startsWith('Memory.')
|| method.startsWith('Tracing.')
|| BLOCKED_DOMAINS.some((domain) => method.startsWith(domain))
);
if (BLOCKED_METHODS.has(method) || blockedHostDomain) {
throw new AgentBrowserFault(
'CDP_METHOD_BLOCKED',
`CDP method ${method} 可能影响 Makelore 宿主,已阻止。`,
false,
);
}
if (method.startsWith('Target.')) {
throw new AgentBrowserFault(
'TARGET_DENIED',
'Target 域由开发浏览器托管,不能直接操作其他 Electron 页面。',
false,
);
}
if (hasExternalTargetReference(params)) {
throw new AgentBrowserFault(
'TARGET_DENIED',
'不能指定开发浏览器之外的 CDP target。',
false,
);
}
for (const field of URL_FIELDS_BY_METHOD.get(method) ?? []) {
const value = params?.[field];
if (typeof value === 'string' && !isAllowedPageUrl(value)) {
throw new AgentBrowserFault(
'CDP_METHOD_BLOCKED',
`${method} 只允许访问 HTTP 或 HTTPS 地址。`,
false,
);
}
}
if (sessionRef && !childSessions.has(sessionRef)) {
throw new AgentBrowserFault(
'TARGET_DENIED',
'CDP session 不属于当前开发浏览器。',
false,
);
}
if (method.startsWith('IO.')) {
const handle = params?.handle;
if (typeof handle !== 'string' || !ioHandles.has(handle)) {
throw new AgentBrowserFault(
'TARGET_DENIED',
'IO handle 不属于当前开发浏览器会话。',
false,
);
}
}
}
}

View File

@@ -0,0 +1,191 @@
import type { BrowserWindow, Session, WebContents } from 'electron';
import { WebContentsView, session } from 'electron';
import type { AgentBrowserBounds } from '../../shared/agent-browser';
import type {
AgentBrowserAdapter,
AgentBrowserDebuggerPort,
AgentBrowserNavigationPort,
AgentBrowserViewPort,
AgentBrowserWebContentsPort,
PortListener,
} from './adapter';
function isAllowedTopLevelUrl(value: string): boolean {
if (value === 'about:blank') return true;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function wrapDebugger(contents: WebContents): AgentBrowserDebuggerPort {
const debuggerEvents = contents.debugger as unknown as {
on(event: string, listener: PortListener): void;
removeListener(event: string, listener: PortListener): void;
};
return {
attach: (version) => contents.debugger.attach(version),
detach: () => contents.debugger.detach(),
isAttached: () => contents.debugger.isAttached(),
sendCommand: (method, params, sessionRef) =>
contents.debugger.sendCommand(method, params, sessionRef),
on: (event, listener) => debuggerEvents.on(event, listener),
removeListener: (event, listener) =>
debuggerEvents.removeListener(event, listener),
};
}
function wrapNavigation(contents: WebContents): AgentBrowserNavigationPort {
return {
canGoBack: () => contents.navigationHistory.canGoBack(),
canGoForward: () => contents.navigationHistory.canGoForward(),
goBack: () => {
contents.navigationHistory.goBack();
},
goForward: () => {
contents.navigationHistory.goForward();
},
clear: () => {
contents.navigationHistory.clear();
},
};
}
function wrapWebContents(contents: WebContents): AgentBrowserWebContentsPort {
const contentsEvents = contents as unknown as {
on(event: string, listener: PortListener): void;
removeListener(event: string, listener: PortListener): void;
};
return {
debugger: wrapDebugger(contents),
navigationHistory: wrapNavigation(contents),
loadURL: (url) => contents.loadURL(url),
getURL: () => contents.getURL(),
getTitle: () => contents.getTitle(),
isDestroyed: () => contents.isDestroyed(),
isDevToolsOpened: () => contents.isDevToolsOpened(),
reload: () => contents.reload(),
denyWindowOpen: () => {
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
},
on: (event: string, listener: PortListener) => {
contentsEvents.on(event, listener);
},
removeListener: (event: string, listener: PortListener) => {
contentsEvents.removeListener(event, listener);
},
};
}
export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
private readonly nativeViews = new WeakMap<AgentBrowserViewPort, WebContentsView>();
private readonly guardedSessions = new WeakSet<Session>();
constructor(private readonly mainWindow: BrowserWindow) {}
createView(partition: string): AgentBrowserViewPort {
const nativeView = new WebContentsView({
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
const contents = nativeView.webContents;
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
contents.on('will-attach-webview', (event) => event.preventDefault());
const preventUnsafeNavigation = (
event: Electron.Event,
navigationUrl: string,
) => {
if (!isAllowedTopLevelUrl(navigationUrl)) event.preventDefault();
};
contents.on('will-navigate', preventUnsafeNavigation);
contents.on('will-redirect', preventUnsafeNavigation);
this.guardSession(contents.session);
const view: AgentBrowserViewPort = {
webContents: wrapWebContents(contents),
setBounds: (bounds: AgentBrowserBounds) =>
nativeView.setBounds(this.toNativeBounds(bounds)),
setVisible: (visible: boolean) => nativeView.setVisible(visible),
};
this.nativeViews.set(view, nativeView);
return view;
}
mount(view: AgentBrowserViewPort): void {
const nativeView = this.requireNativeView(view);
if (!this.mainWindow.isDestroyed()) {
this.mainWindow.contentView.addChildView(nativeView);
}
}
unmount(view: AgentBrowserViewPort): void {
const nativeView = this.nativeViews.get(view);
if (!nativeView || this.mainWindow.isDestroyed()) return;
try {
this.mainWindow.contentView.removeChildView(nativeView);
} catch {
// Removing an already detached view is harmless during shutdown.
}
}
destroy(view: AgentBrowserViewPort): void {
const nativeView = this.nativeViews.get(view);
if (!nativeView) return;
this.nativeViews.delete(view);
if (!nativeView.webContents.isDestroyed()) {
nativeView.webContents.close({ waitForBeforeUnload: false });
}
}
async resetPartition(partition: string): Promise<void> {
const browserSession = session.fromPartition(partition);
await browserSession.clearStorageData();
await browserSession.clearCache();
}
private requireNativeView(view: AgentBrowserViewPort): WebContentsView {
const nativeView = this.nativeViews.get(view);
if (!nativeView) throw new Error('Unknown Agent Browser view.');
return nativeView;
}
private guardSession(browserSession: Session): void {
if (this.guardedSessions.has(browserSession)) return;
this.guardedSessions.add(browserSession);
browserSession.setPermissionCheckHandler(() => false);
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => {
callback(false);
});
browserSession.on('will-download', (event) => {
event.preventDefault();
});
}
private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
const zoomFactor = this.mainWindow.webContents.getZoomFactor();
const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
const contentBounds = this.mainWindow.getContentBounds();
const contentWidth = Math.max(0, Math.trunc(contentBounds.width));
const contentHeight = Math.max(0, Math.trunc(contentBounds.height));
const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth);
const y = clamp(Math.round(bounds.y * zoom), 0, contentHeight);
return {
x,
y,
width: clamp(Math.round(bounds.width * zoom), 0, contentWidth - x),
height: clamp(Math.round(bounds.height * zoom), 0, contentHeight - y),
};
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), Math.max(min, max));
}

View File

@@ -0,0 +1,128 @@
import type {
AgentBrowserCdpEvent,
AgentBrowserCdpEventPage,
} from '../../shared/agent-browser';
type GapReason = NonNullable<AgentBrowserCdpEventPage['gap']>['reason'];
interface BufferedEvent {
event: AgentBrowserCdpEvent;
byteLength: number;
}
interface GapMarker {
sequence: number;
reason: GapReason;
}
export interface AgentBrowserEventBufferOptions {
maxEvents?: number;
maxBytes?: number;
}
const DEFAULT_MAX_EVENTS = 10_000;
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
export class AgentBrowserEventBuffer {
private readonly events: BufferedEvent[] = [];
private readonly gaps: GapMarker[] = [];
private readonly maxEvents: number;
private readonly maxBytes: number;
private bytes = 0;
private sequence = 0;
private evictedThrough = 0;
constructor(options: AgentBrowserEventBufferOptions = {}) {
this.maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
}
get cursor(): number {
return this.sequence;
}
push(event: Omit<AgentBrowserCdpEvent, 'sequence'>): AgentBrowserCdpEvent {
const complete = { ...event, sequence: ++this.sequence };
const byteLength = Buffer.byteLength(JSON.stringify(complete));
this.events.push({ event: complete, byteLength });
this.bytes += byteLength;
this.evict();
return complete;
}
markGap(reason: GapReason): number {
const sequence = ++this.sequence;
this.gaps.push({ sequence, reason });
this.pruneGaps();
return sequence;
}
read(after = 0, methods?: string[], limit = 100): AgentBrowserCdpEventPage {
const safeAfter = Math.max(0, Math.trunc(after));
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
const methodSet = methods?.length ? new Set(methods) : null;
const start = Math.max(safeAfter, this.evictedThrough);
const selected: AgentBrowserCdpEvent[] = [];
let scannedThrough = start;
for (const buffered of this.events) {
if (buffered.event.sequence <= start) continue;
scannedThrough = buffered.event.sequence;
if (!methodSet || methodSet.has(buffered.event.method)) {
selected.push(buffered.event);
if (selected.length >= safeLimit) break;
}
}
if (selected.length < safeLimit) {
scannedThrough = Math.max(scannedThrough, this.sequence);
}
const nextCursor = Math.max(safeAfter, scannedThrough);
const hasMore = this.events.some(({ event }) =>
event.sequence > nextCursor && (!methodSet || methodSet.has(event.method)));
const oldestAvailable = this.events[0]?.event.sequence ?? this.sequence + 1;
const gap = safeAfter < this.evictedThrough
? { reason: 'evicted' as const, oldestAvailable }
: this.gaps.find((marker) =>
marker.sequence > safeAfter && marker.sequence <= nextCursor);
return {
events: selected,
nextCursor,
hasMore,
...(gap
? {
gap: {
reason: gap.reason,
oldestAvailable,
},
}
: {}),
};
}
clear(): void {
this.events.length = 0;
this.gaps.length = 0;
this.bytes = 0;
this.sequence = 0;
this.evictedThrough = 0;
}
private evict(): void {
while (this.events.length > this.maxEvents || this.bytes > this.maxBytes) {
const removed = this.events.shift();
if (!removed) break;
this.bytes -= removed.byteLength;
this.evictedThrough = Math.max(this.evictedThrough, removed.event.sequence);
}
this.pruneGaps();
}
private pruneGaps(): void {
while (this.gaps[0] && this.gaps[0].sequence <= this.evictedThrough) {
this.gaps.shift();
}
}
}

View File

@@ -0,0 +1,18 @@
import type {
AgentBrowserErrorCode,
AgentBrowserFaultShape,
} from '../../shared/agent-browser';
export class AgentBrowserFault extends Error implements AgentBrowserFaultShape {
readonly name = 'AgentBrowserFault';
constructor(
readonly code: AgentBrowserErrorCode,
message: string,
readonly retryable: boolean,
readonly generation?: number,
readonly outcome?: 'unknown',
) {
super(message);
}
}

View File

@@ -0,0 +1,26 @@
export type {
AgentBrowserAdapter,
AgentBrowserDebuggerPort,
AgentBrowserNavigationPort,
AgentBrowserViewPort,
AgentBrowserWebContentsPort,
} from './adapter';
export { AgentBrowserCdpGuard } from './cdp-guard';
export { ElectronAgentBrowserAdapter } from './electron-adapter';
export { AgentBrowserEventBuffer } from './event-buffer';
export { AgentBrowserFault } from './fault';
export {
AgentBrowserModule,
agentBrowserPartition,
} from './module';
export type {
AgentBrowserModuleOptions,
AgentBrowserNavigateInput,
AgentBrowserOpenInput,
AgentBrowserPresentInput,
AgentBrowserReadEventsInput,
AgentBrowserReadPayloadInput,
AgentBrowserSendCdpInput,
} from './module';
export { AgentBrowserPayloadStore } from './payload-store';
export type { PayloadStoreOptions } from './payload-store';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
import { randomUUID } from 'node:crypto';
import type {
AgentBrowserPayloadChunk,
AgentBrowserPayloadRef,
} from '../../shared/agent-browser';
import { AgentBrowserFault } from './fault';
type PayloadContentType = AgentBrowserPayloadRef['contentType'];
interface PayloadEntry {
data: Buffer;
contentType: PayloadContentType;
expiresAt: number;
}
export interface PayloadStoreOptions {
maxBytes?: number;
maxEntryBytes?: number;
ttlMs?: number;
now?: () => number;
}
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
const DEFAULT_TTL_MS = 60_000;
const DEFAULT_CHUNK_BYTES = 256 * 1024;
const MAX_CHUNK_BYTES = 1024 * 1024;
export class AgentBrowserPayloadStore {
private readonly entries = new Map<string, PayloadEntry>();
private readonly maxBytes: number;
private readonly maxEntryBytes: number;
private readonly ttlMs: number;
private readonly now: () => number;
private totalBytes = 0;
constructor(options: PayloadStoreOptions = {}) {
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.now = options.now ?? Date.now;
}
putJson(value: unknown): AgentBrowserPayloadRef {
let encoded: Buffer;
try {
encoded = Buffer.from(JSON.stringify(value) ?? 'null');
} catch {
throw new AgentBrowserFault(
'CDP_PROTOCOL_ERROR',
'CDP 返回了无法序列化的数据。',
false,
);
}
return this.put(encoded, 'application/json');
}
put(data: Buffer, contentType: PayloadContentType): AgentBrowserPayloadRef {
this.purgeExpired();
if (data.byteLength > this.maxEntryBytes || data.byteLength > this.maxBytes) {
throw new AgentBrowserFault(
'PAYLOAD_TOO_LARGE',
`CDP 数据超过 ${this.maxEntryBytes} 字节的单项上限。`,
false,
);
}
while (this.totalBytes + data.byteLength > this.maxBytes) {
const oldest = this.entries.keys().next().value as string | undefined;
if (!oldest) break;
this.delete(oldest);
}
const handle = randomUUID();
const expiresAt = this.now() + this.ttlMs;
this.entries.set(handle, {
data: Buffer.from(data),
contentType,
expiresAt,
});
this.totalBytes += data.byteLength;
return {
kind: 'payload',
handle,
byteLength: data.byteLength,
contentType,
expiresAt: new Date(expiresAt).toISOString(),
};
}
read(handle: string, offset = 0, maxBytes = DEFAULT_CHUNK_BYTES): AgentBrowserPayloadChunk {
this.purgeExpired();
const entry = this.entries.get(handle);
if (!entry) {
throw new AgentBrowserFault(
'PAYLOAD_NOT_FOUND',
'CDP 数据已过期或不存在。',
false,
);
}
if (!Number.isInteger(offset) || offset < 0 || offset > entry.data.byteLength) {
throw new AgentBrowserFault('INVALID_REQUEST', 'Payload offset 无效。', false);
}
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
throw new AgentBrowserFault('INVALID_REQUEST', 'Payload maxBytes 无效。', false);
}
const requestedBytes = Math.min(
maxBytes,
MAX_CHUNK_BYTES,
);
let nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength);
const isText = entry.contentType !== 'application/octet-stream';
if (isText && offset > 0 && (entry.data[offset] & 0xc0) === 0x80) {
throw new AgentBrowserFault(
'INVALID_REQUEST',
'文本 Payload offset 必须位于 UTF-8 字符边界。',
false,
);
}
if (isText && nextOffset < entry.data.byteLength) {
while (nextOffset > offset && (entry.data[nextOffset] & 0xc0) === 0x80) {
nextOffset -= 1;
}
if (nextOffset === offset) {
nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength);
while (
nextOffset < entry.data.byteLength
&& (entry.data[nextOffset] & 0xc0) === 0x80
) {
nextOffset += 1;
}
}
}
const chunk = entry.data.subarray(offset, nextOffset);
return {
handle,
offset,
nextOffset,
byteLength: entry.data.byteLength,
contentType: entry.contentType,
data: isText ? chunk.toString('utf8') : chunk.toString('base64'),
encoding: isText ? 'utf8' : 'base64',
done: nextOffset >= entry.data.byteLength,
};
}
delete(handle: string): void {
const entry = this.entries.get(handle);
if (!entry) return;
this.entries.delete(handle);
this.totalBytes -= entry.data.byteLength;
}
clear(): void {
this.entries.clear();
this.totalBytes = 0;
}
private purgeExpired(): void {
const now = this.now();
for (const [handle, entry] of this.entries) {
if (entry.expiresAt <= now) this.delete(handle);
}
}
}