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

@@ -13,6 +13,7 @@ import {
} from '../opencode/playwright-mcp';
import { resolveOpencodeRuntimePaths } from '../opencode/paths';
import {
resolveBundledAgentBrowserPluginPath,
resolveBundledCourseSkillsDir,
resolveBundledSuperpowersDir,
} from '../opencode/superpowers';
@@ -61,6 +62,7 @@ import { acquireProcessInstanceFileLock } from './process-instance-lock';
import { getHostApiToken, startHostApiServer } from '../api/server';
import { HostEventBus } from '../api/event-bus';
import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser';
import { browserOAuthManager } from '../utils/browser-oauth';
import { createProjectProgressSync } from '../services/project-progress-sync';
import { createWorksCloudDeployment } from '../services/works-cloud-deployment';
@@ -148,6 +150,7 @@ let hostEventBus!: HostEventBus;
let hostApiServer: Server | null = null;
let projectProgressSync: ReturnType<typeof createProjectProgressSync> | null = null;
let worksCloudDeployment: ReturnType<typeof createWorksCloudDeployment> | null = null;
let agentBrowser: AgentBrowserModule | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
@@ -309,6 +312,23 @@ function registerMakeloreProtocolClient(): void {
function createMainWindow(): BrowserWindow {
const win = createWindow();
const closeAgentBrowserForHostRenderer = (reason: string): void => {
void agentBrowser?.close().catch((error) => {
logger.warn(`Failed to close Agent Browser after ${reason}:`, error);
});
};
win.webContents.on('render-process-gone', () => {
closeAgentBrowserForHostRenderer('the main renderer exited');
});
win.webContents.on(
'did-start-navigation',
(_event, _url, isInPlace, isMainFrame) => {
if (isMainFrame && !isInPlace) {
closeAgentBrowserForHostRenderer('the main renderer started navigation');
}
},
);
win.once('ready-to-show', () => {
if (mainWindow !== win) {
return;
@@ -331,6 +351,11 @@ function createMainWindow(): BrowserWindow {
});
win.on('closed', () => {
const browser = agentBrowser;
agentBrowser = null;
void browser?.dispose().catch((error) => {
logger.warn('Failed to dispose Agent Browser after the main window closed:', error);
});
if (mainWindow === win) {
mainWindow = null;
}
@@ -403,6 +428,7 @@ async function initialize(): Promise<void> {
// Create the main window
const window = createMainWindow();
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
// Create system tray
if (!isE2EMode) {
@@ -417,6 +443,7 @@ async function initialize(): Promise<void> {
opencodeProjectStore,
eventBus: hostEventBus,
mainWindow: window,
agentBrowser,
worksCloudDeployment: worksCloudDeployment ?? undefined,
imageWorkspace,
});
@@ -505,12 +532,18 @@ if (gotTheLock) {
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
});
const bundledAgentBrowserPluginPath = resolveBundledAgentBrowserPluginPath({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
});
opencodeManager = new OpencodeManager({
port: 4096,
binPath: opencodePaths.binPath,
userDataDir: app.getPath('userData'),
bundledSuperpowersDir,
bundledCourseSkillsDir,
bundledAgentBrowserPluginPath,
pythonRuntime,
runtimeConfigProvider: async () => {
const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({
@@ -608,7 +641,13 @@ if (gotTheLock) {
const stopOpencodePromise = opencodeManager.stop().catch((err) => {
logger.warn('opencodeManager.stop() error during quit:', err);
});
const stopPromise = Promise.allSettled([stopOpencodePromise]);
const stopAgentBrowserPromise = agentBrowser?.dispose().catch((err) => {
logger.warn('agentBrowser.dispose() error during quit:', err);
}) ?? Promise.resolve();
const stopPromise = Promise.allSettled([
stopOpencodePromise,
stopAgentBrowserPromise,
]);
const timeoutPromise = new Promise<'timeout'>((resolve) => {
setTimeout(() => resolve('timeout'), 5000);
});
@@ -629,6 +668,11 @@ if (gotTheLock) {
logger.error(`${reason}:`, error);
projectProgressSync?.stop();
worksCloudDeployment?.stop();
try {
void agentBrowser?.dispose().catch(() => { /* ignore */ });
} catch {
// ignore — dispose() may not be callable if state is corrupted
}
try {
void opencodeManager?.stop().catch(() => { /* ignore */ });
} catch {

View File

@@ -1,6 +1,10 @@
import { ipcMain } from 'electron';
import { getPort } from '../../utils/config';
import { getHostApiToken } from '../../api/server';
import {
getRendererCapability,
RENDERER_CAPABILITY_HEADER,
} from '../../api/renderer-capability';
type HostApiFetchRequest = {
path: string;
@@ -28,6 +32,7 @@ export function registerHostApiProxyHandlers(): void {
const headers: Record<string, string> = { ...(request.headers || {}) };
// Inject the per-session auth token so the Host API server accepts this request.
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability();
let body: string | undefined;
if (request.body !== undefined && request.body !== null) {