feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
191
electron/agent-browser/electron-adapter.ts
Normal file
191
electron/agent-browser/electron-adapter.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user