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