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

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

371 lines
13 KiB
TypeScript

import { realpath } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AgentBrowserBounds, AgentBrowserFaultShape } from '../../../shared/agent-browser';
import { normalizeProjectPath } from '../../opencode/project-store';
import type { HostApiContext } from '../context';
import { hasRendererCapability } from '../renderer-capability';
import { parseJsonBody, sendJson } from '../route-utils';
type AgentBrowserBody = {
project_path?: unknown;
url?: unknown;
action?: unknown;
visible?: unknown;
bounds?: unknown;
method?: unknown;
params?: unknown;
session_ref?: unknown;
timeout_ms?: unknown;
after?: unknown;
methods?: unknown;
limit?: unknown;
wait_ms?: unknown;
handle?: unknown;
offset?: unknown;
max_bytes?: unknown;
};
class AgentBrowserRouteError extends Error {
constructor(
readonly code: AgentBrowserFaultShape['code'],
message: string,
readonly status = 400,
) {
super(message);
}
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function finiteInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value)
? Math.trunc(value)
: undefined;
}
function parseBounds(value: unknown): AgentBrowserBounds | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
const x = finiteInteger(record.x);
const y = finiteInteger(record.y);
const width = finiteInteger(record.width);
const height = finiteInteger(record.height);
if (x === undefined || y === undefined || width === undefined || height === undefined) {
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器区域坐标不完整。');
}
if (width < 1 || height < 1) {
throw new AgentBrowserRouteError('VIEWPORT_NOT_READY', '浏览器区域尚未准备好。');
}
return { x, y, width, height };
}
function parseStringArray(value: unknown): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP 事件过滤器格式无效。');
}
return value.map((item) => item.trim()).filter(Boolean);
}
async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
if (!activeProject) {
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409);
}
let activeRealPath: string;
try {
activeRealPath = await realpath(activeProject.path);
} catch {
throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409);
}
const requested = nonEmptyString(requestedPath);
if (!requested) {
throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。');
}
let requestedRealPath: string;
try {
requestedRealPath = await realpath(requested);
} catch {
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '请求的项目目录不可用。', 403);
}
if (normalizeProjectPath(requestedRealPath) !== normalizeProjectPath(activeRealPath)) {
throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403);
}
return {
...activeProject,
path: activeRealPath,
};
}
async function ensureProjectStillActive(
ctx: HostApiContext,
project: { id: string; path: string },
): Promise<void> {
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
let activeRealPath: string | null = null;
if (activeProject?.id === project.id) {
try {
activeRealPath = await realpath(activeProject.path);
} catch {
activeRealPath = null;
}
}
if (
activeProject?.id === project.id
&& activeRealPath
&& normalizeProjectPath(activeRealPath) === normalizeProjectPath(project.path)
) {
return;
}
try {
await ctx.agentBrowser?.close(project.path);
} catch {
// A new project's browser may already own the module; never close it.
}
throw new AgentBrowserRouteError(
'PROJECT_MISMATCH',
'项目已切换,本次浏览器操作已取消。',
403,
);
}
function requireService(ctx: HostApiContext) {
if (!ctx.agentBrowser) {
throw new AgentBrowserRouteError('CLOSED', '开发浏览器尚未初始化。', 503);
}
return ctx.agentBrowser;
}
function requireRendererPresentation(req: IncomingMessage): void {
if (!hasRendererCapability(req)) {
throw new AgentBrowserRouteError(
'TARGET_DENIED',
'浏览器显示区域只能由 Makelore 界面控制。',
403,
);
}
}
function emitState(ctx: HostApiContext, eventName: 'agent-browser:show' | 'agent-browser:state', payload: unknown): void {
ctx.eventBus.emit(eventName, payload);
const webContents = ctx.mainWindow?.webContents;
if (webContents && !webContents.isDestroyed()) {
webContents.send(eventName, payload);
}
}
function faultFrom(error: unknown): AgentBrowserFaultShape | null {
if (!error || typeof error !== 'object') return null;
const record = error as Partial<AgentBrowserFaultShape>;
if (typeof record.code !== 'string' || typeof record.message !== 'string') return null;
return {
code: record.code,
message: record.message,
retryable: record.retryable === true,
...(typeof record.generation === 'number' ? { generation: record.generation } : {}),
...(record.outcome === 'unknown' ? { outcome: 'unknown' } : {}),
};
}
function faultStatus(code: AgentBrowserFaultShape['code']): number {
if (code === 'PROJECT_MISMATCH' || code === 'TARGET_DENIED' || code === 'CDP_METHOD_BLOCKED') return 403;
if (code === 'BROWSER_NOT_OPEN' || code === 'PAYLOAD_NOT_FOUND' || code === 'TARGET_GONE') return 404;
if (code === 'CURSOR_EXPIRED') return 410;
if (code === 'DEVTOOLS_CONFLICT' || code === 'DEBUGGER_BUSY' || code === 'PROJECT_NOT_ACTIVE') return 409;
if (code === 'CDP_TIMEOUT') return 504;
if (code === 'ATTACH_FAILED' || code === 'RENDERER_CRASHED' || code === 'CLOSED') return 503;
return 400;
}
async function readBody(req: IncomingMessage): Promise<AgentBrowserBody> {
return await parseJsonBody<AgentBrowserBody>(req);
}
export async function handleAgentBrowserRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (!url.pathname.startsWith('/api/agent-browser')) return false;
try {
const service = requireService(ctx);
if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') {
const project = await resolveActiveProject(ctx, url.searchParams.get('project_path'));
const browser = await service.getSnapshot(project.path);
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const rendererPresentation = hasRendererCapability(req);
if (body.bounds !== undefined && !rendererPresentation) {
requireRendererPresentation(req);
}
const targetUrl = nonEmptyString(body.url);
if (!targetUrl) throw new AgentBrowserRouteError('INVALID_URL', '请输入要打开的网页地址。');
const browser = await service.open({
projectId: project.id,
projectPath: project.path,
url: targetUrl,
bounds: parseBounds(body.bounds),
visible: rendererPresentation && body.visible !== false,
});
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:show', browser);
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') {
requireRendererPresentation(req);
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const browser = await service.present({
projectPath: project.path,
visible: body.visible === true,
bounds: parseBounds(body.bounds),
});
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const action = nonEmptyString(body.action);
if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') {
throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。');
}
const browser = await service.navigate({
projectPath: project.path,
action,
url: nonEmptyString(body.url),
});
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', browser);
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const method = nonEmptyString(body.method);
if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。');
const params = body.params === undefined
? undefined
: body.params && typeof body.params === 'object' && !Array.isArray(body.params)
? body.params as Record<string, unknown>
: (() => {
throw new AgentBrowserRouteError('INVALID_REQUEST', 'CDP params 必须是对象。');
})();
const result = await service.sendCdp({
projectPath: project.path,
method,
params,
sessionRef: nonEmptyString(body.session_ref),
timeoutMs: finiteInteger(body.timeout_ms),
});
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, result });
return true;
}
if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const page = await service.readEvents({
projectPath: project.path,
after: finiteInteger(body.after),
methods: parseStringArray(body.methods),
limit: finiteInteger(body.limit),
waitMs: finiteInteger(body.wait_ms),
});
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, page });
return true;
}
if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const handle = nonEmptyString(body.handle);
if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。');
const chunk = await service.readPayload({
projectPath: project.path,
handle,
offset: finiteInteger(body.offset),
maxBytes: finiteInteger(body.max_bytes),
});
await ensureProjectStillActive(ctx, project);
sendJson(res, 200, { success: true, chunk });
return true;
}
if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const browser = await service.close(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {
...browser,
projectId: project.id,
projectPath: project.path,
});
sendJson(res, 200, { success: true, browser });
return true;
}
if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') {
const body = await readBody(req);
const project = await resolveActiveProject(ctx, body.project_path);
const browser = await service.resetProfile(project.path);
await ensureProjectStillActive(ctx, project);
emitState(ctx, 'agent-browser:state', {
...browser,
projectId: project.id,
projectPath: project.path,
});
sendJson(res, 200, { success: true, browser });
return true;
}
sendJson(res, 404, {
success: false,
code: 'INVALID_REQUEST',
error: `No Agent Browser route for ${req.method} ${url.pathname}`,
});
return true;
} catch (error) {
const routeError = error instanceof AgentBrowserRouteError ? error : null;
const fault = routeError
? {
code: routeError.code,
message: routeError.message,
retryable: false,
}
: faultFrom(error);
if (fault) {
sendJson(res, routeError?.status ?? faultStatus(fault.code), {
success: false,
error: fault.message,
...fault,
});
return true;
}
throw error;
}
}