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; 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 { 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; 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 { return await parseJsonBody(req); } export async function handleAgentBrowserRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { 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 : (() => { 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; } }