From e97a7ce9df56f8a408e9c915b2d94322339bd896 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 14:53:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=85=B1=E4=BA=AB=20?= =?UTF-8?q?Agent=20Browser=20=E8=B0=83=E8=AF=95=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。 --- .opencode/plugins/agent-browser-tools.ts | 223 +++ .../plugins/niancode-agent-browser.js | 186 +++ .opencode/skills/agent-browser/SKILL.md | 35 + README.md | 9 +- electron/agent-browser/adapter.ts | 52 + electron/agent-browser/cdp-guard.ts | 108 ++ electron/agent-browser/electron-adapter.ts | 191 +++ electron/agent-browser/event-buffer.ts | 128 ++ electron/agent-browser/fault.ts | 18 + electron/agent-browser/index.ts | 26 + electron/agent-browser/module.ts | 1305 +++++++++++++++++ electron/agent-browser/payload-store.ts | 167 +++ electron/api/context.ts | 52 + electron/api/renderer-capability.ts | 21 + electron/api/routes/agent-browser.ts | 370 +++++ electron/api/routes/opencode.ts | 32 + electron/api/server.ts | 4 + electron/main/index.ts | 46 +- electron/main/ipc/host-api-proxy.ts | 5 + electron/opencode/manager.ts | 6 + electron/opencode/superpowers.ts | 23 + electron/preload/index.ts | 2 + package.json | 1 + pnpm-lock.yaml | 209 ++- shared/agent-browser.ts | 107 ++ src/lib/agent-browser.ts | 409 ++++++ src/lib/host-events.ts | 2 + src/pages/Chat/AgentBrowserPanel.tsx | 680 +++++++++ src/pages/Chat/OpencodeChatPanel.tsx | 7 + tests/unit/agent-browser-core.test.ts | 1023 +++++++++++++ .../agent-browser-electron-adapter.test.ts | 211 +++ tests/unit/agent-browser-panel.test.tsx | 430 ++++++ tests/unit/agent-browser-plugin.test.ts | 124 ++ tests/unit/agent-browser-routes.test.ts | 348 +++++ tests/unit/host-api-proxy.test.ts | 6 + tests/unit/host-events.test.ts | 17 + tests/unit/opencode-chat-panel.test.tsx | 1 + tests/unit/opencode-manager.test.ts | 62 + tests/unit/opencode-routes.test.ts | 97 ++ 39 files changed, 6734 insertions(+), 9 deletions(-) create mode 100644 .opencode/plugins/agent-browser-tools.ts create mode 100644 .opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js create mode 100644 .opencode/skills/agent-browser/SKILL.md create mode 100644 electron/agent-browser/adapter.ts create mode 100644 electron/agent-browser/cdp-guard.ts create mode 100644 electron/agent-browser/electron-adapter.ts create mode 100644 electron/agent-browser/event-buffer.ts create mode 100644 electron/agent-browser/fault.ts create mode 100644 electron/agent-browser/index.ts create mode 100644 electron/agent-browser/module.ts create mode 100644 electron/agent-browser/payload-store.ts create mode 100644 electron/api/renderer-capability.ts create mode 100644 electron/api/routes/agent-browser.ts create mode 100644 shared/agent-browser.ts create mode 100644 src/lib/agent-browser.ts create mode 100644 src/pages/Chat/AgentBrowserPanel.tsx create mode 100644 tests/unit/agent-browser-core.test.ts create mode 100644 tests/unit/agent-browser-electron-adapter.test.ts create mode 100644 tests/unit/agent-browser-panel.test.tsx create mode 100644 tests/unit/agent-browser-plugin.test.ts create mode 100644 tests/unit/agent-browser-routes.test.ts diff --git a/.opencode/plugins/agent-browser-tools.ts b/.opencode/plugins/agent-browser-tools.ts new file mode 100644 index 0000000..dc4a6d1 --- /dev/null +++ b/.opencode/plugins/agent-browser-tools.ts @@ -0,0 +1,223 @@ +import { tool } from '@opencode-ai/plugin/tool'; + +type HostPayload = Record; + +function hostApiBaseUrl(): string { + const value = process.env.NIANCODE_HOST_API_BASE_URL?.trim(); + if (!value) throw new Error('Makelore 开发浏览器服务尚未启动。'); + return value.replace(/\/+$/, ''); +} + +function hostApiToken(): string { + const value = process.env.NIANCODE_HOST_API_TOKEN?.trim(); + if (!value) throw new Error('Makelore 开发浏览器凭证不可用,请重启客户端后再试。'); + return value; +} + +function browserError(payload: HostPayload, status: number): string { + const code = typeof payload.code === 'string' ? payload.code : ''; + const message = typeof payload.message === 'string' + ? payload.message + : typeof payload.error === 'string' + ? payload.error + : ''; + const fallback: Record = { + PROJECT_NOT_ACTIVE: '请先在 Makelore 中打开要调试的项目。', + PROJECT_MISMATCH: '智能体只能调试当前打开的项目。', + BROWSER_NOT_OPEN: '开发浏览器尚未打开,请先调用 browser_context 的 open。', + VIEWPORT_NOT_READY: '开发浏览器显示区域尚未准备好,请稍后重试。', + DEBUGGER_BUSY: '开发浏览器调试器正忙,请稍后重试。', + ATTACH_FAILED: '无法连接开发浏览器调试器,请关闭浏览器面板后重新打开。', + DEVTOOLS_CONFLICT: '当前页面的原生开发者工具已打开;关闭它后智能体才能继续调试。', + CDP_METHOD_BLOCKED: '该 CDP 命令会影响 Makelore 客户端或其他页面,已被阻止。', + TARGET_DENIED: '不能访问当前项目浏览器之外的页面目标。', + CDP_TIMEOUT: 'CDP 命令执行超时且结果未知;开发浏览器已关闭,请重新打开页面并核对状态。', + CURSOR_EXPIRED: '部分较早的浏览器事件已被清理,请从返回的新游标继续读取。', + PAYLOAD_NOT_FOUND: '浏览器数据已过期,请重新执行产生该数据的命令。', + TARGET_GONE: '页面目标已经关闭或刷新,请重新获取页面状态。', + RENDERER_CRASHED: '开发浏览器页面已崩溃,请关闭后重新打开。', + }; + if (code && fallback[code]) return fallback[code]; + if (message) return message; + if (status === 401) return '开发浏览器凭证已失效,请重启 Makelore 后再试。'; + return `开发浏览器操作失败(HTTP ${status})。`; +} + +async function hostRequest( + path: string, + options: { method?: 'GET' | 'POST'; body?: HostPayload }, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(`${hostApiBaseUrl()}${path}`, { + method: options.method ?? 'POST', + headers: { + Authorization: `Bearer ${hostApiToken()}`, + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + signal, + }); + } catch (error) { + if (signal.aborted) throw new Error('开发浏览器操作已取消。', { cause: error }); + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`无法连接 Makelore 开发浏览器服务:${detail}`, { cause: error }); + } + + const payload = await response.json().catch(() => ({})) as HostPayload; + if (!response.ok || payload.success !== true) { + throw new Error(browserError(payload, response.status)); + } + return payload; +} + +function result(title: string, payload: unknown) { + return { + title, + output: JSON.stringify(payload, null, 2), + metadata: { source: 'niancode-agent-browser' }, + }; +} + +export const NianCodeAgentBrowserPlugin = async () => ({ + tool: { + browser_context: tool({ + description: 'Open, inspect, close, or reset the shared Makelore development browser for the current project. The browser is visible to the user and shares its page state with the agent. Use open with a local or public development URL before other browser tools.', + args: { + action: tool.schema.enum(['open', 'status', 'close', 'reset_profile']) + .describe('Browser lifecycle action'), + url: tool.schema.string().optional() + .describe('URL to open. Required only for action=open.'), + }, + async execute(args, context) { + const projectPath = context.directory; + if (args.action === 'status') { + const query = new URLSearchParams({ project_path: projectPath }); + const payload = await hostRequest( + `/api/agent-browser/state?${query.toString()}`, + { method: 'GET' }, + context.abort, + ); + return result('开发浏览器状态', payload.browser); + } + + if (args.action === 'open') { + const url = args.url?.trim(); + if (!url) throw new Error('打开开发浏览器时必须提供 url。'); + const payload = await hostRequest('/api/agent-browser/open', { + body: { project_path: projectPath, url }, + }, context.abort); + return result('已打开开发浏览器', payload.browser); + } + + const path = args.action === 'close' + ? '/api/agent-browser/close' + : '/api/agent-browser/reset-profile'; + const payload = await hostRequest(path, { + body: { project_path: projectPath }, + }, context.abort); + return result( + args.action === 'close' ? '已关闭开发浏览器' : '已清空开发浏览器数据', + payload.browser, + ); + }, + }), + + browser_navigate: tool({ + description: 'Navigate the shared development browser for the current project. Supports opening a URL, back, forward, and reload.', + args: { + action: tool.schema.enum(['url', 'back', 'forward', 'reload']) + .describe('Navigation action'), + url: tool.schema.string().optional() + .describe('Destination URL. Required only for action=url.'), + }, + async execute(args, context) { + const url = args.url?.trim(); + if (args.action === 'url' && !url) { + throw new Error('跳转网页时必须提供 url。'); + } + const payload = await hostRequest('/api/agent-browser/navigate', { + body: { + project_path: context.directory, + action: args.action, + ...(url ? { url } : {}), + }, + }, context.abort); + return result('开发浏览器已导航', payload.browser); + }, + }), + + browser_cdp: tool({ + description: 'Use Full Chrome DevTools Protocol on the current project browser. send forwards one CDP method and params without summarizing the result; read_events returns raw buffered CDP events after a cursor; read_payload reads a chunk referenced by a previous result.', + args: { + action: tool.schema.enum(['send', 'read_events', 'read_payload']) + .describe('CDP operation'), + method: tool.schema.string().optional() + .describe('Full CDP method name, required for send (for example Runtime.evaluate)'), + params: tool.schema.object({}).passthrough().optional() + .describe('Raw CDP params for send'), + session_ref: tool.schema.string().optional() + .describe('Child-target session reference returned by this browser'), + timeout_ms: tool.schema.number().int().positive().optional() + .describe('Optional CDP command timeout'), + after: tool.schema.number().int().nonnegative().optional() + .describe('Read events with sequence greater than this cursor'), + methods: tool.schema.array(tool.schema.string()).optional() + .describe('Optional exact CDP event method filters'), + limit: tool.schema.number().int().positive().optional() + .describe('Maximum events to return'), + wait_ms: tool.schema.number().int().nonnegative().optional() + .describe('Optional event long-poll duration'), + handle: tool.schema.string().optional() + .describe('Payload handle, required for read_payload'), + offset: tool.schema.number().int().nonnegative().optional() + .describe('Payload byte offset'), + max_bytes: tool.schema.number().int().positive().optional() + .describe('Maximum payload bytes in this chunk'), + }, + async execute(args, context) { + if (args.action === 'send') { + const method = args.method?.trim(); + if (!method) throw new Error('发送 CDP 命令时必须提供 method。'); + const payload = await hostRequest('/api/agent-browser/cdp/send', { + body: { + project_path: context.directory, + method, + ...(args.params ? { params: args.params } : {}), + ...(args.session_ref ? { session_ref: args.session_ref } : {}), + ...(args.timeout_ms !== undefined ? { timeout_ms: args.timeout_ms } : {}), + }, + }, context.abort); + return result(`CDP ${method}`, payload.result); + } + + if (args.action === 'read_events') { + const payload = await hostRequest('/api/agent-browser/cdp/events', { + body: { + project_path: context.directory, + ...(args.after !== undefined ? { after: args.after } : {}), + ...(args.methods ? { methods: args.methods } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), + ...(args.wait_ms !== undefined ? { wait_ms: args.wait_ms } : {}), + }, + }, context.abort); + return result('CDP 事件', payload.page); + } + + const handle = args.handle?.trim(); + if (!handle) throw new Error('读取大型 CDP 数据时必须提供 handle。'); + const payload = await hostRequest('/api/agent-browser/payload/read', { + body: { + project_path: context.directory, + handle, + ...(args.offset !== undefined ? { offset: args.offset } : {}), + ...(args.max_bytes !== undefined ? { max_bytes: args.max_bytes } : {}), + }, + }, context.abort); + return result('CDP 数据分块', payload.chunk); + }, + }), + }, +}); diff --git a/.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js b/.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js new file mode 100644 index 0000000..3f9b4bf --- /dev/null +++ b/.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js @@ -0,0 +1,186 @@ +// plugins/agent-browser-tools.ts +import { tool } from "@opencode-ai/plugin/tool"; +function hostApiBaseUrl() { + const value = process.env.NIANCODE_HOST_API_BASE_URL?.trim(); + if (!value) throw new Error("Makelore \u5F00\u53D1\u6D4F\u89C8\u5668\u670D\u52A1\u5C1A\u672A\u542F\u52A8\u3002"); + return value.replace(/\/+$/, ""); +} +function hostApiToken() { + const value = process.env.NIANCODE_HOST_API_TOKEN?.trim(); + if (!value) throw new Error("Makelore \u5F00\u53D1\u6D4F\u89C8\u5668\u51ED\u8BC1\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u542F\u5BA2\u6237\u7AEF\u540E\u518D\u8BD5\u3002"); + return value; +} +function browserError(payload, status) { + const code = typeof payload.code === "string" ? payload.code : ""; + const message = typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : ""; + const fallback = { + PROJECT_NOT_ACTIVE: "\u8BF7\u5148\u5728 Makelore \u4E2D\u6253\u5F00\u8981\u8C03\u8BD5\u7684\u9879\u76EE\u3002", + PROJECT_MISMATCH: "\u667A\u80FD\u4F53\u53EA\u80FD\u8C03\u8BD5\u5F53\u524D\u6253\u5F00\u7684\u9879\u76EE\u3002", + BROWSER_NOT_OPEN: "\u5F00\u53D1\u6D4F\u89C8\u5668\u5C1A\u672A\u6253\u5F00\uFF0C\u8BF7\u5148\u8C03\u7528 browser_context \u7684 open\u3002", + VIEWPORT_NOT_READY: "\u5F00\u53D1\u6D4F\u89C8\u5668\u663E\u793A\u533A\u57DF\u5C1A\u672A\u51C6\u5907\u597D\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", + DEBUGGER_BUSY: "\u5F00\u53D1\u6D4F\u89C8\u5668\u8C03\u8BD5\u5668\u6B63\u5FD9\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002", + ATTACH_FAILED: "\u65E0\u6CD5\u8FDE\u63A5\u5F00\u53D1\u6D4F\u89C8\u5668\u8C03\u8BD5\u5668\uFF0C\u8BF7\u5173\u95ED\u6D4F\u89C8\u5668\u9762\u677F\u540E\u91CD\u65B0\u6253\u5F00\u3002", + DEVTOOLS_CONFLICT: "\u5F53\u524D\u9875\u9762\u7684\u539F\u751F\u5F00\u53D1\u8005\u5DE5\u5177\u5DF2\u6253\u5F00\uFF1B\u5173\u95ED\u5B83\u540E\u667A\u80FD\u4F53\u624D\u80FD\u7EE7\u7EED\u8C03\u8BD5\u3002", + CDP_METHOD_BLOCKED: "\u8BE5 CDP \u547D\u4EE4\u4F1A\u5F71\u54CD Makelore \u5BA2\u6237\u7AEF\u6216\u5176\u4ED6\u9875\u9762\uFF0C\u5DF2\u88AB\u963B\u6B62\u3002", + TARGET_DENIED: "\u4E0D\u80FD\u8BBF\u95EE\u5F53\u524D\u9879\u76EE\u6D4F\u89C8\u5668\u4E4B\u5916\u7684\u9875\u9762\u76EE\u6807\u3002", + CDP_TIMEOUT: "CDP \u547D\u4EE4\u6267\u884C\u8D85\u65F6\u4E14\u7ED3\u679C\u672A\u77E5\uFF1B\u5F00\u53D1\u6D4F\u89C8\u5668\u5DF2\u5173\u95ED\uFF0C\u8BF7\u91CD\u65B0\u6253\u5F00\u9875\u9762\u5E76\u6838\u5BF9\u72B6\u6001\u3002", + CURSOR_EXPIRED: "\u90E8\u5206\u8F83\u65E9\u7684\u6D4F\u89C8\u5668\u4E8B\u4EF6\u5DF2\u88AB\u6E05\u7406\uFF0C\u8BF7\u4ECE\u8FD4\u56DE\u7684\u65B0\u6E38\u6807\u7EE7\u7EED\u8BFB\u53D6\u3002", + PAYLOAD_NOT_FOUND: "\u6D4F\u89C8\u5668\u6570\u636E\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C\u4EA7\u751F\u8BE5\u6570\u636E\u7684\u547D\u4EE4\u3002", + TARGET_GONE: "\u9875\u9762\u76EE\u6807\u5DF2\u7ECF\u5173\u95ED\u6216\u5237\u65B0\uFF0C\u8BF7\u91CD\u65B0\u83B7\u53D6\u9875\u9762\u72B6\u6001\u3002", + RENDERER_CRASHED: "\u5F00\u53D1\u6D4F\u89C8\u5668\u9875\u9762\u5DF2\u5D29\u6E83\uFF0C\u8BF7\u5173\u95ED\u540E\u91CD\u65B0\u6253\u5F00\u3002" + }; + if (code && fallback[code]) return fallback[code]; + if (message) return message; + if (status === 401) return "\u5F00\u53D1\u6D4F\u89C8\u5668\u51ED\u8BC1\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u542F Makelore \u540E\u518D\u8BD5\u3002"; + return `\u5F00\u53D1\u6D4F\u89C8\u5668\u64CD\u4F5C\u5931\u8D25\uFF08HTTP ${status}\uFF09\u3002`; +} +async function hostRequest(path, options, signal) { + let response; + try { + response = await fetch(`${hostApiBaseUrl()}${path}`, { + method: options.method ?? "POST", + headers: { + Authorization: `Bearer ${hostApiToken()}`, + Accept: "application/json", + ...options.body ? { "Content-Type": "application/json" } : {} + }, + ...options.body ? { body: JSON.stringify(options.body) } : {}, + signal + }); + } catch (error) { + if (signal.aborted) throw new Error("\u5F00\u53D1\u6D4F\u89C8\u5668\u64CD\u4F5C\u5DF2\u53D6\u6D88\u3002", { cause: error }); + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`\u65E0\u6CD5\u8FDE\u63A5 Makelore \u5F00\u53D1\u6D4F\u89C8\u5668\u670D\u52A1\uFF1A${detail}`, { cause: error }); + } + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success !== true) { + throw new Error(browserError(payload, response.status)); + } + return payload; +} +function result(title, payload) { + return { + title, + output: JSON.stringify(payload, null, 2), + metadata: { source: "niancode-agent-browser" } + }; +} +var NianCodeAgentBrowserPlugin = async () => ({ + tool: { + browser_context: tool({ + description: "Open, inspect, close, or reset the shared Makelore development browser for the current project. The browser is visible to the user and shares its page state with the agent. Use open with a local or public development URL before other browser tools.", + args: { + action: tool.schema.enum(["open", "status", "close", "reset_profile"]).describe("Browser lifecycle action"), + url: tool.schema.string().optional().describe("URL to open. Required only for action=open.") + }, + async execute(args, context) { + const projectPath = context.directory; + if (args.action === "status") { + const query = new URLSearchParams({ project_path: projectPath }); + const payload2 = await hostRequest( + `/api/agent-browser/state?${query.toString()}`, + { method: "GET" }, + context.abort + ); + return result("\u5F00\u53D1\u6D4F\u89C8\u5668\u72B6\u6001", payload2.browser); + } + if (args.action === "open") { + const url = args.url?.trim(); + if (!url) throw new Error("\u6253\u5F00\u5F00\u53D1\u6D4F\u89C8\u5668\u65F6\u5FC5\u987B\u63D0\u4F9B url\u3002"); + const payload2 = await hostRequest("/api/agent-browser/open", { + body: { project_path: projectPath, url } + }, context.abort); + return result("\u5DF2\u6253\u5F00\u5F00\u53D1\u6D4F\u89C8\u5668", payload2.browser); + } + const path = args.action === "close" ? "/api/agent-browser/close" : "/api/agent-browser/reset-profile"; + const payload = await hostRequest(path, { + body: { project_path: projectPath } + }, context.abort); + return result( + args.action === "close" ? "\u5DF2\u5173\u95ED\u5F00\u53D1\u6D4F\u89C8\u5668" : "\u5DF2\u6E05\u7A7A\u5F00\u53D1\u6D4F\u89C8\u5668\u6570\u636E", + payload.browser + ); + } + }), + browser_navigate: tool({ + description: "Navigate the shared development browser for the current project. Supports opening a URL, back, forward, and reload.", + args: { + action: tool.schema.enum(["url", "back", "forward", "reload"]).describe("Navigation action"), + url: tool.schema.string().optional().describe("Destination URL. Required only for action=url.") + }, + async execute(args, context) { + const url = args.url?.trim(); + if (args.action === "url" && !url) { + throw new Error("\u8DF3\u8F6C\u7F51\u9875\u65F6\u5FC5\u987B\u63D0\u4F9B url\u3002"); + } + const payload = await hostRequest("/api/agent-browser/navigate", { + body: { + project_path: context.directory, + action: args.action, + ...url ? { url } : {} + } + }, context.abort); + return result("\u5F00\u53D1\u6D4F\u89C8\u5668\u5DF2\u5BFC\u822A", payload.browser); + } + }), + browser_cdp: tool({ + description: "Use Full Chrome DevTools Protocol on the current project browser. send forwards one CDP method and params without summarizing the result; read_events returns raw buffered CDP events after a cursor; read_payload reads a chunk referenced by a previous result.", + args: { + action: tool.schema.enum(["send", "read_events", "read_payload"]).describe("CDP operation"), + method: tool.schema.string().optional().describe("Full CDP method name, required for send (for example Runtime.evaluate)"), + params: tool.schema.object({}).passthrough().optional().describe("Raw CDP params for send"), + session_ref: tool.schema.string().optional().describe("Child-target session reference returned by this browser"), + timeout_ms: tool.schema.number().int().positive().optional().describe("Optional CDP command timeout"), + after: tool.schema.number().int().nonnegative().optional().describe("Read events with sequence greater than this cursor"), + methods: tool.schema.array(tool.schema.string()).optional().describe("Optional exact CDP event method filters"), + limit: tool.schema.number().int().positive().optional().describe("Maximum events to return"), + wait_ms: tool.schema.number().int().nonnegative().optional().describe("Optional event long-poll duration"), + handle: tool.schema.string().optional().describe("Payload handle, required for read_payload"), + offset: tool.schema.number().int().nonnegative().optional().describe("Payload byte offset"), + max_bytes: tool.schema.number().int().positive().optional().describe("Maximum payload bytes in this chunk") + }, + async execute(args, context) { + if (args.action === "send") { + const method = args.method?.trim(); + if (!method) throw new Error("\u53D1\u9001 CDP \u547D\u4EE4\u65F6\u5FC5\u987B\u63D0\u4F9B method\u3002"); + const payload2 = await hostRequest("/api/agent-browser/cdp/send", { + body: { + project_path: context.directory, + method, + ...args.params ? { params: args.params } : {}, + ...args.session_ref ? { session_ref: args.session_ref } : {}, + ...args.timeout_ms !== void 0 ? { timeout_ms: args.timeout_ms } : {} + } + }, context.abort); + return result(`CDP ${method}`, payload2.result); + } + if (args.action === "read_events") { + const payload2 = await hostRequest("/api/agent-browser/cdp/events", { + body: { + project_path: context.directory, + ...args.after !== void 0 ? { after: args.after } : {}, + ...args.methods ? { methods: args.methods } : {}, + ...args.limit !== void 0 ? { limit: args.limit } : {}, + ...args.wait_ms !== void 0 ? { wait_ms: args.wait_ms } : {} + } + }, context.abort); + return result("CDP \u4E8B\u4EF6", payload2.page); + } + const handle = args.handle?.trim(); + if (!handle) throw new Error("\u8BFB\u53D6\u5927\u578B CDP \u6570\u636E\u65F6\u5FC5\u987B\u63D0\u4F9B handle\u3002"); + const payload = await hostRequest("/api/agent-browser/payload/read", { + body: { + project_path: context.directory, + handle, + ...args.offset !== void 0 ? { offset: args.offset } : {}, + ...args.max_bytes !== void 0 ? { max_bytes: args.max_bytes } : {} + } + }, context.abort); + return result("CDP \u6570\u636E\u5206\u5757", payload.chunk); + } + }) + } +}); +export { + NianCodeAgentBrowserPlugin +}; diff --git a/.opencode/skills/agent-browser/SKILL.md b/.opencode/skills/agent-browser/SKILL.md new file mode 100644 index 0000000..773f83d --- /dev/null +++ b/.opencode/skills/agent-browser/SKILL.md @@ -0,0 +1,35 @@ +--- +name: agent-browser +description: 当需要打开、查看或调试当前 Makelore 项目的本地或公网网页,读取 Console、Network、DOM、样式、性能信息,执行页面脚本或刷新验证修改时使用。 +--- + +# Makelore 开发浏览器 + +开发浏览器是用户和智能体共享的项目调试界面。它只用于开发过程,不代表作品已经发布。 + +## 基本流程 + +1. 确认项目的开发服务已经启动,并取得本地网页地址。 +2. 调用 `browser_context` 的 `open` 打开该地址。 +3. 调用 `browser_cdp` 的 `read_events` 读取 Console 与 Network 事件。 +4. 需要主动检查页面时,使用 `browser_cdp` 的 `send` 调用标准 CDP 方法。 +5. 修改代码后调用 `browser_navigate` 的 `reload`,再读取新事件验证结果。 + +## 工具选择 + +- `browser_context`:打开、查看状态、关闭浏览器或清空当前项目的浏览器数据。 +- `browser_navigate`:跳转、后退、前进或刷新共享页面。 +- `browser_cdp`: + - `send` 原样执行当前项目页面的 CDP 命令; + - `read_events` 按游标读取原始事件,不会消费其他读取者的数据; + - `read_payload` 分块读取响应体、截图等大型结果。 + +## 约束 + +- 不要传递或猜测 `projectId`、`tabId`、`targetId`、`webContentsId`;项目身份由当前会话目录自动绑定。 +- 不要通过远程调试端口、`curl` 或另一个浏览器绕过这些工具。 +- 页面刷新或调试器重连后,旧的 DOM node、RemoteObject 和子 Target 引用可能失效,应重新获取。 +- 开发浏览器面板收起或被模态框遮挡时,智能体调试会暂停;等待用户重新显示页面后再继续。 +- `CDP_TIMEOUT` 表示结果未知,客户端会关闭该浏览器以清理悬挂命令;不要盲目重复可能产生副作用的命令,先重新打开页面并核对状态。 +- 原生 DevTools 与智能体调试冲突时,请用户关闭该页面的原生 DevTools 后再继续。 +- Console、请求头、响应体、Cookie 和页面内容可能包含敏感信息,只在完成当前调试所必需时读取和展示。 diff --git a/README.md b/README.md index 532d81d..c027461 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程运行时:Electron Main 管理项目内声明的 `opencode-ai` 依赖,Renderer 不直接启动或调用运行时。 +- 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - AI 绘画:生产环境使用云端工作区契约;上游不可用时展示明确的不可用状态。未打包开发环境可使用隔离的本地适配器。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 @@ -68,7 +69,7 @@ pnpm run package:linux | `src/` | React Renderer、页面、组件和状态管理 | | `electron/` | Electron Main、Preload、Host API、运行时与系统能力 | | `shared/` | Main 与 Renderer 共享的契约和项目配置 | -| `.opencode/` | 随产品提供的 Skill 定义 | +| `.opencode/` | 随产品提供的 Skill 与 OpenCode 插件 | | `resources/` | 品牌、图标和打包资源 | | `scripts/` | 运行时准备、图标生成、打包与验证脚本 | | `tests/` | Vitest、Electron runtime 与 Playwright 测试 | @@ -82,6 +83,12 @@ pnpm run package:linux - AI 绘画使用独立的云端工作区边界,不回退到 AI 编程项目数据。 - Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。 +### 共享开发浏览器 + +- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 +- 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 +- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板收起或被弹窗遮挡时隐藏原生页面并暂停 Agent 调试;该能力独立于发布和部署。 + ### 项目联系人与会话 - 新项目默认没有联系人或 Agent;用户在项目内手动创建联系人时必须填写名称、预设头像、职责和精确的 `provider/model`,提示词与 Skill 属于后置高级设置。 diff --git a/electron/agent-browser/adapter.ts b/electron/agent-browser/adapter.ts new file mode 100644 index 0000000..d25f3c7 --- /dev/null +++ b/electron/agent-browser/adapter.ts @@ -0,0 +1,52 @@ +import type { AgentBrowserBounds } from '../../shared/agent-browser'; + +export type PortListener = (...args: unknown[]) => void; + +export interface AgentBrowserDebuggerPort { + attach(protocolVersion: string): void; + detach(): void; + isAttached(): boolean; + sendCommand( + method: string, + params?: Record, + sessionRef?: string, + ): Promise; + on(event: 'message' | 'detach', listener: PortListener): void; + removeListener(event: 'message' | 'detach', listener: PortListener): void; +} + +export interface AgentBrowserNavigationPort { + canGoBack(): boolean; + canGoForward(): boolean; + goBack(): void; + goForward(): void; + clear(): void; +} + +export interface AgentBrowserWebContentsPort { + readonly debugger: AgentBrowserDebuggerPort; + readonly navigationHistory: AgentBrowserNavigationPort; + loadURL(url: string): Promise; + getURL(): string; + getTitle(): string; + isDestroyed(): boolean; + isDevToolsOpened(): boolean; + reload(): void; + denyWindowOpen(): void; + on(event: string, listener: PortListener): void; + removeListener(event: string, listener: PortListener): void; +} + +export interface AgentBrowserViewPort { + readonly webContents: AgentBrowserWebContentsPort; + setBounds(bounds: AgentBrowserBounds): void; + setVisible(visible: boolean): void; +} + +export interface AgentBrowserAdapter { + createView(partition: string): AgentBrowserViewPort; + mount(view: AgentBrowserViewPort): void; + unmount(view: AgentBrowserViewPort): void; + destroy(view: AgentBrowserViewPort): void; + resetPartition(partition: string): Promise; +} diff --git a/electron/agent-browser/cdp-guard.ts b/electron/agent-browser/cdp-guard.ts new file mode 100644 index 0000000..5e2ad81 --- /dev/null +++ b/electron/agent-browser/cdp-guard.ts @@ -0,0 +1,108 @@ +import { AgentBrowserFault } from './fault'; + +const BLOCKED_METHODS = new Set([ + 'DOM.getFileInfo', + 'DOM.setFileInputFiles', + 'Page.crash', + 'Page.setDownloadBehavior', + 'Security.handleCertificateError', + 'Security.setDisableNetworkAccessForOrigins', + 'Security.setIgnoreCertificateErrors', + 'Security.setOverrideCertificateErrors', +]); + +const BLOCKED_DOMAINS = [ + 'Cast.', + 'DeviceAccess.', + 'Extensions.', + 'Tethering.', +] as const; + +const URL_FIELDS_BY_METHOD = new Map([ + ['Fetch.continueRequest', ['url']], + ['Network.continueInterceptedRequest', ['url']], + ['Network.loadNetworkResource', ['url']], + ['Page.navigate', ['url']], +]); + +function hasExternalTargetReference(params: Record | undefined): boolean { + return Boolean(params && ( + typeof params.targetId === 'string' + || typeof params.browserContextId === 'string' + )); +} + +function isAllowedPageUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export class AgentBrowserCdpGuard { + assertAllowed( + method: string, + params: Record | undefined, + sessionRef: string | undefined, + childSessions: ReadonlySet, + ioHandles: ReadonlySet, + ): void { + const blockedHostDomain = ( + (method.startsWith('Browser.') && method !== 'Browser.getVersion') + || method.startsWith('SystemInfo.') + || method.startsWith('Memory.') + || method.startsWith('Tracing.') + || BLOCKED_DOMAINS.some((domain) => method.startsWith(domain)) + ); + if (BLOCKED_METHODS.has(method) || blockedHostDomain) { + throw new AgentBrowserFault( + 'CDP_METHOD_BLOCKED', + `CDP method ${method} 可能影响 Makelore 宿主,已阻止。`, + false, + ); + } + if (method.startsWith('Target.')) { + throw new AgentBrowserFault( + 'TARGET_DENIED', + 'Target 域由开发浏览器托管,不能直接操作其他 Electron 页面。', + false, + ); + } + if (hasExternalTargetReference(params)) { + throw new AgentBrowserFault( + 'TARGET_DENIED', + '不能指定开发浏览器之外的 CDP target。', + false, + ); + } + for (const field of URL_FIELDS_BY_METHOD.get(method) ?? []) { + const value = params?.[field]; + if (typeof value === 'string' && !isAllowedPageUrl(value)) { + throw new AgentBrowserFault( + 'CDP_METHOD_BLOCKED', + `${method} 只允许访问 HTTP 或 HTTPS 地址。`, + false, + ); + } + } + if (sessionRef && !childSessions.has(sessionRef)) { + throw new AgentBrowserFault( + 'TARGET_DENIED', + 'CDP session 不属于当前开发浏览器。', + false, + ); + } + if (method.startsWith('IO.')) { + const handle = params?.handle; + if (typeof handle !== 'string' || !ioHandles.has(handle)) { + throw new AgentBrowserFault( + 'TARGET_DENIED', + 'IO handle 不属于当前开发浏览器会话。', + false, + ); + } + } + } +} diff --git a/electron/agent-browser/electron-adapter.ts b/electron/agent-browser/electron-adapter.ts new file mode 100644 index 0000000..90a563e --- /dev/null +++ b/electron/agent-browser/electron-adapter.ts @@ -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(); + private readonly guardedSessions = new WeakSet(); + + 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 { + 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)); +} diff --git a/electron/agent-browser/event-buffer.ts b/electron/agent-browser/event-buffer.ts new file mode 100644 index 0000000..696e782 --- /dev/null +++ b/electron/agent-browser/event-buffer.ts @@ -0,0 +1,128 @@ +import type { + AgentBrowserCdpEvent, + AgentBrowserCdpEventPage, +} from '../../shared/agent-browser'; + +type GapReason = NonNullable['reason']; + +interface BufferedEvent { + event: AgentBrowserCdpEvent; + byteLength: number; +} + +interface GapMarker { + sequence: number; + reason: GapReason; +} + +export interface AgentBrowserEventBufferOptions { + maxEvents?: number; + maxBytes?: number; +} + +const DEFAULT_MAX_EVENTS = 10_000; +const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; + +export class AgentBrowserEventBuffer { + private readonly events: BufferedEvent[] = []; + private readonly gaps: GapMarker[] = []; + private readonly maxEvents: number; + private readonly maxBytes: number; + private bytes = 0; + private sequence = 0; + private evictedThrough = 0; + + constructor(options: AgentBrowserEventBufferOptions = {}) { + this.maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + } + + get cursor(): number { + return this.sequence; + } + + push(event: Omit): AgentBrowserCdpEvent { + const complete = { ...event, sequence: ++this.sequence }; + const byteLength = Buffer.byteLength(JSON.stringify(complete)); + this.events.push({ event: complete, byteLength }); + this.bytes += byteLength; + this.evict(); + return complete; + } + + markGap(reason: GapReason): number { + const sequence = ++this.sequence; + this.gaps.push({ sequence, reason }); + this.pruneGaps(); + return sequence; + } + + read(after = 0, methods?: string[], limit = 100): AgentBrowserCdpEventPage { + const safeAfter = Math.max(0, Math.trunc(after)); + const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200); + const methodSet = methods?.length ? new Set(methods) : null; + const start = Math.max(safeAfter, this.evictedThrough); + const selected: AgentBrowserCdpEvent[] = []; + let scannedThrough = start; + + for (const buffered of this.events) { + if (buffered.event.sequence <= start) continue; + scannedThrough = buffered.event.sequence; + if (!methodSet || methodSet.has(buffered.event.method)) { + selected.push(buffered.event); + if (selected.length >= safeLimit) break; + } + } + + if (selected.length < safeLimit) { + scannedThrough = Math.max(scannedThrough, this.sequence); + } + + const nextCursor = Math.max(safeAfter, scannedThrough); + const hasMore = this.events.some(({ event }) => + event.sequence > nextCursor && (!methodSet || methodSet.has(event.method))); + const oldestAvailable = this.events[0]?.event.sequence ?? this.sequence + 1; + const gap = safeAfter < this.evictedThrough + ? { reason: 'evicted' as const, oldestAvailable } + : this.gaps.find((marker) => + marker.sequence > safeAfter && marker.sequence <= nextCursor); + + return { + events: selected, + nextCursor, + hasMore, + ...(gap + ? { + gap: { + reason: gap.reason, + oldestAvailable, + }, + } + : {}), + }; + } + + clear(): void { + this.events.length = 0; + this.gaps.length = 0; + this.bytes = 0; + this.sequence = 0; + this.evictedThrough = 0; + } + + private evict(): void { + while (this.events.length > this.maxEvents || this.bytes > this.maxBytes) { + const removed = this.events.shift(); + if (!removed) break; + this.bytes -= removed.byteLength; + this.evictedThrough = Math.max(this.evictedThrough, removed.event.sequence); + } + this.pruneGaps(); + } + + private pruneGaps(): void { + while (this.gaps[0] && this.gaps[0].sequence <= this.evictedThrough) { + this.gaps.shift(); + } + } +} diff --git a/electron/agent-browser/fault.ts b/electron/agent-browser/fault.ts new file mode 100644 index 0000000..e50d2ef --- /dev/null +++ b/electron/agent-browser/fault.ts @@ -0,0 +1,18 @@ +import type { + AgentBrowserErrorCode, + AgentBrowserFaultShape, +} from '../../shared/agent-browser'; + +export class AgentBrowserFault extends Error implements AgentBrowserFaultShape { + readonly name = 'AgentBrowserFault'; + + constructor( + readonly code: AgentBrowserErrorCode, + message: string, + readonly retryable: boolean, + readonly generation?: number, + readonly outcome?: 'unknown', + ) { + super(message); + } +} diff --git a/electron/agent-browser/index.ts b/electron/agent-browser/index.ts new file mode 100644 index 0000000..0018876 --- /dev/null +++ b/electron/agent-browser/index.ts @@ -0,0 +1,26 @@ +export type { + AgentBrowserAdapter, + AgentBrowserDebuggerPort, + AgentBrowserNavigationPort, + AgentBrowserViewPort, + AgentBrowserWebContentsPort, +} from './adapter'; +export { AgentBrowserCdpGuard } from './cdp-guard'; +export { ElectronAgentBrowserAdapter } from './electron-adapter'; +export { AgentBrowserEventBuffer } from './event-buffer'; +export { AgentBrowserFault } from './fault'; +export { + AgentBrowserModule, + agentBrowserPartition, +} from './module'; +export type { + AgentBrowserModuleOptions, + AgentBrowserNavigateInput, + AgentBrowserOpenInput, + AgentBrowserPresentInput, + AgentBrowserReadEventsInput, + AgentBrowserReadPayloadInput, + AgentBrowserSendCdpInput, +} from './module'; +export { AgentBrowserPayloadStore } from './payload-store'; +export type { PayloadStoreOptions } from './payload-store'; diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts new file mode 100644 index 0000000..0d72fdb --- /dev/null +++ b/electron/agent-browser/module.ts @@ -0,0 +1,1305 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import type { + AgentBrowserBounds, + AgentBrowserCdpEventPage, + AgentBrowserCdpResult, + AgentBrowserErrorCode, + AgentBrowserPayloadChunk, + AgentBrowserPayloadRef, + AgentBrowserSnapshot, + AgentBrowserState, +} from '../../shared/agent-browser'; +import type { + AgentBrowserAdapter, + AgentBrowserViewPort, + PortListener, +} from './adapter'; +import { AgentBrowserCdpGuard } from './cdp-guard'; +import { AgentBrowserEventBuffer } from './event-buffer'; +import { AgentBrowserFault } from './fault'; +import { AgentBrowserPayloadStore } from './payload-store'; + +const CDP_PROTOCOL_VERSION = '1.3'; +const INLINE_RESULT_BYTES = 64 * 1024; +const MAX_WAIT_MS = 5_000; +const DEFAULT_CDP_TIMEOUT_MS = 10_000; +const MAX_CDP_TIMEOUT_MS = 30_000; +const OPEN_TIMEOUT_MS = 30_000; +const RENDERER_PRIME_URL = 'about:blank'; +const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const; +const STREAM_ISSUING_METHODS = new Set([ + 'Fetch.takeResponseBodyAsStream', + 'Network.loadNetworkResource', + 'Page.printToPDF', + 'Tracing.tracingComplete', +]); + +interface BrowserRecord { + browserId: string; + projectId: string; + projectPath: string; + partition: string; + view: AgentBrowserViewPort; + state: AgentBrowserState; + generation: number; + url: string; + title: string; + visible: boolean; + bounds: AgentBrowserBounds | null; + error?: { + code: AgentBrowserErrorCode; + message: string; + }; + eventBuffer: AgentBrowserEventBuffer; + childSessions: Set; + ioHandles: Set; + webContentsListeners: Array<{ event: string; listener: PortListener }>; + debuggerListeners: Array<{ + event: 'message' | 'detach'; + listener: PortListener; + }>; + interruptWaiters: Set<(fault: AgentBrowserFault) => void>; +} + +export interface AgentBrowserOpenInput { + projectId: string; + projectPath: string; + url: string; + bounds?: AgentBrowserBounds; + visible?: boolean; +} + +export interface AgentBrowserPresentInput { + projectPath: string; + visible: boolean; + bounds?: AgentBrowserBounds; +} + +export interface AgentBrowserNavigateInput { + projectPath: string; + action: 'url' | 'back' | 'forward' | 'reload'; + url?: string; +} + +export interface AgentBrowserSendCdpInput { + projectPath: string; + method: string; + params?: Record; + sessionRef?: string; + timeoutMs?: number; +} + +export interface AgentBrowserReadEventsInput { + projectPath: string; + after?: number; + methods?: string[]; + limit?: number; + waitMs?: number; +} + +export interface AgentBrowserReadPayloadInput { + projectPath: string; + handle: string; + offset?: number; + maxBytes?: number; +} + +export interface AgentBrowserModuleOptions { + payloadStore?: AgentBrowserPayloadStore; + cdpGuard?: AgentBrowserCdpGuard; +} + +export function agentBrowserPartition(projectPath: string): string { + const normalized = normalizePath(projectPath); + const key = createHash('sha256').update(normalized).digest('hex').slice(0, 32); + return `persist:niancode-agent-browser:${key}`; +} + +export class AgentBrowserModule { + private readonly payloadStore: AgentBrowserPayloadStore; + private readonly cdpGuard: AgentBrowserCdpGuard; + private readonly eventWaiters = new Set<() => void>(); + private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>(); + private record: BrowserRecord | null = null; + private commandTail: Promise = Promise.resolve(); + private lifecycleBarrier: Promise = Promise.resolve(); + private queueEpoch = 0; + private generation = 0; + private disposed = false; + + constructor( + private readonly adapter: AgentBrowserAdapter, + options: AgentBrowserModuleOptions = {}, + ) { + this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore(); + this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard(); + } + + async getSnapshot(projectPath?: string): Promise { + if (!this.record) return this.closedSnapshot(); + if (projectPath) this.assertProject(this.record, projectPath); + return this.snapshot(this.record); + } + + open(input: AgentBrowserOpenInput): Promise { + return this.serialize(async () => { + this.assertAvailable(); + const projectPath = normalizeRequiredPath(input.projectPath); + const targetUrl = normalizeUrl(input.url); + const bounds = input.bounds ? normalizeBounds(input.bounds) : null; + + if (this.record && !samePath(this.record.projectPath, projectPath)) { + await this.closeInternal(); + } + if (this.record) { + const record = this.record; + if (record.state === 'suspended_devtools') { + throw new AgentBrowserFault( + 'DEVTOOLS_CONFLICT', + '请先关闭当前页面的原生 DevTools。', + true, + record.generation, + ); + } + if (record.state !== 'attached') { + await this.closeInternal(); + } + } + if (this.record) { + const record = this.record; + if (bounds) { + this.applyPresentation(record, input.visible ?? true, bounds); + } + if (record.url !== targetUrl || record.error) { + await this.navigateTo(record, targetUrl); + } + return this.snapshot(record); + } + + const view = this.adapter.createView(agentBrowserPartition(projectPath)); + const record: BrowserRecord = { + browserId: randomUUID(), + projectId: input.projectId, + projectPath, + partition: agentBrowserPartition(projectPath), + view, + state: 'opening', + generation: ++this.generation, + url: targetUrl, + title: '', + visible: Boolean(bounds) && (input.visible ?? true), + bounds, + eventBuffer: new AgentBrowserEventBuffer(), + childSessions: new Set(), + ioHandles: new Set(), + webContentsListeners: [], + debuggerListeners: [], + interruptWaiters: new Set(), + }; + this.record = record; + + this.registerListeners(record); + record.view.webContents.denyWindowOpen(); + if (bounds) record.view.setBounds(bounds); + record.view.setVisible(record.visible); + this.adapter.mount(record.view); + + try { + await this.runWhileActive( + record, + record.view.webContents.loadURL(RENDERER_PRIME_URL), + OPEN_TIMEOUT_MS, + ); + await this.runWhileActive( + record, + this.attachDebugger(record, false), + OPEN_TIMEOUT_MS, + ); + } catch (error) { + await this.closeInternal(record); + throw toFault( + error, + 'ATTACH_FAILED', + '无法连接开发浏览器调试协议。', + true, + record.generation, + ); + } + + try { + await this.loadPageUntilReady(record, targetUrl); + try { + record.view.webContents.navigationHistory.clear(); + } catch { + // The first real page remains usable if Chromium history is unavailable. + } + } catch (error) { + await this.closeInternal(record); + throw toFault( + error, + 'CDP_PROTOCOL_ERROR', + '页面加载失败。', + true, + record.generation, + ); + } + this.refreshMetadata(record); + return this.snapshot(record); + }); + } + + present(input: AgentBrowserPresentInput): Promise { + return this.serialize(async () => { + const record = this.requireRecord(input.projectPath); + if (input.visible && !input.bounds) { + throw new AgentBrowserFault( + 'VIEWPORT_NOT_READY', + '显示开发浏览器时必须提供当前显示区域。', + true, + record.generation, + ); + } + const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds; + this.applyPresentation(record, input.visible, bounds); + return this.snapshot(record); + }); + } + + navigate(input: AgentBrowserNavigateInput): Promise { + return this.serialize(async () => { + const record = this.requireAttached(input.projectPath); + const navigation = record.view.webContents.navigationHistory; + if (input.action === 'url') { + if (!input.url) { + throw new AgentBrowserFault('INVALID_URL', '缺少网页地址。', false); + } + await this.navigateTo(record, normalizeUrl(input.url)); + } else if (input.action === 'back') { + if (navigation.canGoBack()) navigation.goBack(); + } else if (input.action === 'forward') { + if (navigation.canGoForward()) navigation.goForward(); + } else { + record.view.webContents.reload(); + } + this.refreshMetadata(record); + return this.snapshot(record); + }); + } + + sendCdp(input: AgentBrowserSendCdpInput): Promise { + const timeoutMs = normalizeTimeout(input.timeoutMs); + return this.serializeWithTimeout(async () => { + const record = this.requireAttached(input.projectPath); + const method = input.method.trim(); + if (!method) { + throw new AgentBrowserFault('INVALID_REQUEST', 'CDP method 不能为空。', false); + } + this.cdpGuard.assertAllowed( + method, + input.params, + input.sessionRef, + record.childSessions, + record.ioHandles, + ); + + let result: unknown; + try { + result = await record.view.webContents.debugger.sendCommand( + method, + input.params, + input.sessionRef, + ); + } catch (error) { + throw toFault( + error, + 'CDP_PROTOCOL_ERROR', + `CDP method ${method} 执行失败。`, + true, + record.generation, + ); + } + if ( + record !== this.record + || record.state !== 'attached' + || record.generation !== this.generation + ) { + throw new AgentBrowserFault( + 'CLOSED', + 'CDP 命令完成前开发浏览器已关闭或切换。', + true, + record.generation, + 'unknown', + ); + } + this.captureIoHandles(method, result, record); + if (method === 'IO.close' && typeof input.params?.handle === 'string') { + record.ioHandles.delete(input.params.handle); + } + if (method === 'IO.read' && isRecord(result) && result.eof === true + && typeof input.params?.handle === 'string') { + record.ioHandles.delete(input.params.handle); + } + return this.encodeResult(result); + }, timeoutMs); + } + + async readEvents(input: AgentBrowserReadEventsInput): Promise { + let record = this.requirePresented(input.projectPath); + const after = normalizeNonNegativeInteger(input.after, 0, 'after'); + const limit = normalizeIntegerRange(input.limit, 100, 1, 200, 'limit'); + const waitMs = normalizeIntegerRange(input.waitMs, 0, 0, MAX_WAIT_MS, 'waitMs'); + if (after > record.eventBuffer.cursor) { + throw new AgentBrowserFault( + 'INVALID_REQUEST', + '事件游标超出当前浏览器事件范围。', + false, + record.generation, + ); + } + + let page = record.eventBuffer.read(after, input.methods, limit); + if ( + page.events.length > 0 + || page.gap + || waitMs === 0 + || page.nextCursor < record.eventBuffer.cursor + ) { + return page; + } + + const cursorBeforeWait = record.eventBuffer.cursor; + await this.waitForEvent(waitMs, () => { + const current = this.record; + return !current + || current !== record + || current.eventBuffer.cursor !== cursorBeforeWait; + }); + record = this.requirePresented(input.projectPath); + page = record.eventBuffer.read(page.nextCursor, input.methods, limit); + return page; + } + + async readPayload(input: AgentBrowserReadPayloadInput): Promise { + this.requirePresented(input.projectPath); + return this.payloadStore.read(input.handle, input.offset, input.maxBytes); + } + + async close(projectPath?: string): Promise { + if (this.record && projectPath) this.assertProject(this.record, projectPath); + this.preemptCommands('开发浏览器已关闭。'); + await this.closeInternal(); + return this.closedSnapshot(); + } + + async resetProfile(projectPath: string): Promise { + const normalized = normalizeRequiredPath(projectPath); + if (this.record) this.assertProject(this.record, normalized); + this.preemptCommands('开发浏览器数据正在重置。'); + const previousBarrier = this.lifecycleBarrier; + let releaseBarrier: (() => void) | undefined; + const barrier = new Promise((resolvePromise) => { + releaseBarrier = resolvePromise; + }); + this.lifecycleBarrier = barrier; + try { + await previousBarrier; + await this.closeInternal(); + await this.adapter.resetPartition(agentBrowserPartition(normalized)); + return this.closedSnapshot(); + } finally { + releaseBarrier?.(); + } + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.preemptCommands('开发浏览器模块已关闭。'); + await this.closeInternal(); + this.payloadStore.clear(); + } + + private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise { + if (record !== this.record || record.view.webContents.isDestroyed()) { + throw new AgentBrowserFault( + 'TARGET_GONE', + '开发浏览器页面已关闭。', + true, + record.generation, + ); + } + const port = record.view.webContents.debugger; + const needsAttach = !port.isAttached(); + if (reattach && needsAttach) { + record.generation = ++this.generation; + record.childSessions.clear(); + record.ioHandles.clear(); + } + record.state = 'attaching'; + record.error = undefined; + if (needsAttach) port.attach(CDP_PROTOCOL_VERSION); + + for (const method of ENABLED_DOMAINS) { + await port.sendCommand(method); + } + await port.sendCommand('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }); + if (record !== this.record || record.view.webContents.isDestroyed()) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器调试器连接完成前页面已关闭或切换。', + true, + record.generation, + 'unknown', + ); + } + record.state = 'attached'; + record.error = undefined; + } + + private registerListeners(record: BrowserRecord): void { + const contents = record.view.webContents; + const onDebuggerMessage: PortListener = ( + _event, + methodValue, + params, + sessionRefValue, + ) => { + if (record !== this.record || typeof methodValue !== 'string') return; + const sessionRef = typeof sessionRefValue === 'string' ? sessionRefValue : undefined; + this.updateTargetScope(record, methodValue, params); + this.captureIoHandles(methodValue, params, record); + this.appendEvent(record, methodValue, params, sessionRef); + }; + const onDebuggerDetach: PortListener = (_event, reasonValue) => { + if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { + return; + } + record.eventBuffer.markGap('debugger-detached'); + record.childSessions.clear(); + record.ioHandles.clear(); + const devToolsOpen = !contents.isDestroyed() && contents.isDevToolsOpened(); + record.state = devToolsOpen ? 'suspended_devtools' : 'detached_fault'; + record.error = { + code: devToolsOpen ? 'DEVTOOLS_CONFLICT' : 'DEBUGGER_BUSY', + message: devToolsOpen + ? '原生 DevTools 正在使用当前页面调试器。' + : `开发浏览器调试器已断开:${String(reasonValue ?? 'unknown')}`, + }; + this.notifyEventWaiters(); + }; + this.addDebuggerListener(record, 'message', onDebuggerMessage); + this.addDebuggerListener(record, 'detach', onDebuggerDetach); + + this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => { + if (typeof urlValue === 'string') record.url = urlValue; + this.refreshMetadata(record); + }); + this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue) => { + if (typeof urlValue === 'string') record.url = urlValue; + this.refreshMetadata(record); + }); + this.addWebContentsListener(record, 'page-title-updated', (_event, titleValue) => { + if (typeof titleValue === 'string') record.title = titleValue; + }); + this.addWebContentsListener(record, 'devtools-opened', () => { + if (record !== this.record || record.state === 'closing') return; + record.state = 'suspended_devtools'; + record.error = { + code: 'DEVTOOLS_CONFLICT', + message: '原生 DevTools 已打开,智能体调试暂时暂停。', + }; + }); + this.addWebContentsListener(record, 'devtools-closed', () => { + if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { + return; + } + void this.serialize(async () => { + if (record !== this.record || record.view.webContents.isDestroyed()) return; + try { + await this.runWhileActive( + record, + this.attachDebugger(record, true), + OPEN_TIMEOUT_MS, + ); + } catch (error) { + if (record !== this.record || record.view.webContents.isDestroyed()) return; + record.state = 'detached_fault'; + const fault = toFault( + error, + 'ATTACH_FAILED', + '关闭 DevTools 后无法恢复智能体调试。', + true, + record.generation, + ); + record.error = { code: fault.code, message: fault.message }; + } + }).catch(() => undefined); + }); + this.addWebContentsListener(record, 'render-process-gone', () => { + if (record !== this.record || record.state === 'closing') return; + record.state = 'crashed'; + record.error = { + code: 'RENDERER_CRASHED', + message: '开发浏览器页面进程已退出。', + }; + record.eventBuffer.markGap('view-recreated'); + record.childSessions.clear(); + record.ioHandles.clear(); + this.notifyEventWaiters(); + }); + this.addWebContentsListener(record, 'destroyed', () => { + if (record !== this.record || record.state === 'closing') return; + record.state = 'crashed'; + record.error = { + code: 'TARGET_GONE', + message: '开发浏览器页面已关闭。', + }; + record.eventBuffer.markGap('view-recreated'); + this.notifyEventWaiters(); + }); + } + + private appendEvent( + record: BrowserRecord, + method: string, + params: unknown, + sessionRef?: string, + ): void { + const base = { + generation: record.generation, + timestamp: Date.now(), + method, + ...(sessionRef ? { sessionRef } : {}), + }; + let eventParams = params; + let payload: AgentBrowserPayloadRef | undefined; + try { + const bytes = Buffer.byteLength(JSON.stringify({ ...base, params })); + if (bytes > INLINE_RESULT_BYTES) { + payload = this.payloadStore.putJson(params); + eventParams = undefined; + } + } catch { + eventParams = { omitted: true, reason: 'payload-too-large-or-invalid' }; + } + record.eventBuffer.push({ + ...base, + ...(eventParams === undefined ? {} : { params: eventParams }), + ...(payload ? { payload } : {}), + }); + this.notifyEventWaiters(); + } + + private updateTargetScope(record: BrowserRecord, method: string, params: unknown): void { + if (!isRecord(params)) return; + if (method === 'Target.attachedToTarget' && typeof params.sessionId === 'string') { + record.childSessions.add(params.sessionId); + } else if ( + method === 'Target.detachedFromTarget' + && typeof params.sessionId === 'string' + ) { + record.childSessions.delete(params.sessionId); + } + } + + private captureIoHandles(method: string, value: unknown, record: BrowserRecord): void { + if (!STREAM_ISSUING_METHODS.has(method)) return; + collectStringFields(value, 'stream', record.ioHandles); + } + + private encodeResult(result: unknown): AgentBrowserCdpResult { + let byteLength: number; + try { + byteLength = Buffer.byteLength(JSON.stringify(result) ?? 'null'); + } catch { + throw new AgentBrowserFault( + 'CDP_PROTOCOL_ERROR', + 'CDP 返回了无法序列化的数据。', + false, + ); + } + return byteLength > INLINE_RESULT_BYTES + ? this.payloadStore.putJson(result) + : { kind: 'inline', value: result }; + } + + private applyPresentation( + record: BrowserRecord, + visible: boolean, + bounds: AgentBrowserBounds | null, + ): void { + if (bounds) { + record.view.setBounds(bounds); + record.bounds = bounds; + } + record.view.setVisible(visible); + record.visible = visible; + } + + private async navigateTo(record: BrowserRecord, targetUrl: string): Promise { + try { + await this.loadPageUntilReady(record, targetUrl); + record.url = targetUrl; + record.error = undefined; + } catch (error) { + throw toFault( + error, + 'CDP_PROTOCOL_ERROR', + '页面导航失败。', + true, + record.generation, + ); + } + } + + private async loadPageUntilReady( + record: BrowserRecord, + targetUrl: string, + ): Promise { + const contents = record.view.webContents; + await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let timer: ReturnType | undefined; + let interrupt: ((fault: AgentBrowserFault) => void) | undefined; + const cleanup = () => { + this.removeWebContentsListener(record, 'dom-ready', onDomReady); + this.removeWebContentsListener(record, 'did-fail-load', onDidFailLoad); + if (timer) clearTimeout(timer); + if (interrupt) record.interruptWaiters.delete(interrupt); + }; + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolvePromise(); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + rejectPromise(error); + }; + const onDomReady: PortListener = () => { + succeed(); + }; + const onDidFailLoad: PortListener = ( + _event, + errorCodeValue, + errorDescriptionValue, + _validatedUrl, + isMainFrameValue, + ) => { + if (isMainFrameValue === false) return; + if ( + errorCodeValue === -3 + || errorDescriptionValue === 'ERR_ABORTED' + ) { + return; + } + fail(new Error( + typeof errorDescriptionValue === 'string' && errorDescriptionValue + ? errorDescriptionValue + : '页面加载失败。', + )); + }; + interrupt = fail; + + this.addWebContentsListener(record, 'dom-ready', onDomReady); + this.addWebContentsListener(record, 'did-fail-load', onDidFailLoad); + record.interruptWaiters.add(interrupt); + timer = setTimeout(() => { + fail(new AgentBrowserFault( + 'CDP_TIMEOUT', + `开发浏览器操作在 ${OPEN_TIMEOUT_MS}ms 内未完成。`, + true, + record.generation, + 'unknown', + )); + }, OPEN_TIMEOUT_MS); + + try { + contents.loadURL(targetUrl).then(succeed, (error: unknown) => { + if (!isNavigationAbort(error)) fail(error); + }); + } catch (error) { + fail(error); + } + }); + } + + private refreshMetadata(record: BrowserRecord): void { + if (record.view.webContents.isDestroyed()) return; + record.url = safeUrl(record.view, record.url); + try { + record.title = record.view.webContents.getTitle(); + } catch { + // Metadata is best effort while a navigation is committing. + } + } + + private snapshot(record: BrowserRecord): AgentBrowserSnapshot { + this.refreshMetadata(record); + const contents = record.view.webContents; + let canGoBack = false; + let canGoForward = false; + if (!contents.isDestroyed()) { + try { + canGoBack = contents.navigationHistory.canGoBack(); + canGoForward = contents.navigationHistory.canGoForward(); + } catch { + // Navigation state can disappear while the renderer is crashing. + } + } + return { + browserId: record.browserId, + projectId: record.projectId, + projectPath: record.projectPath, + state: record.state, + generation: record.generation, + url: record.url, + title: record.title, + visible: record.visible, + bounds: record.bounds, + canGoBack, + canGoForward, + eventCursor: record.eventBuffer.cursor, + ...(record.error ? { error: record.error } : {}), + }; + } + + private closedSnapshot(): AgentBrowserSnapshot { + return { + browserId: null, + projectId: null, + projectPath: null, + state: 'closed', + generation: this.generation, + url: '', + title: '', + visible: false, + bounds: null, + canGoBack: false, + canGoForward: false, + eventCursor: 0, + }; + } + + private requireRecord(projectPath: string): BrowserRecord { + this.assertAvailable(); + const record = this.record; + if (!record) { + throw new AgentBrowserFault('BROWSER_NOT_OPEN', '开发浏览器尚未打开。', true); + } + this.assertProject(record, projectPath); + return record; + } + + private requireAttached(projectPath: string): BrowserRecord { + const record = this.requirePresented(projectPath); + if (record.state === 'suspended_devtools') { + throw new AgentBrowserFault( + 'DEVTOOLS_CONFLICT', + '请先关闭当前页面的原生 DevTools。', + true, + record.generation, + ); + } + if (record.state === 'crashed') { + throw new AgentBrowserFault( + 'RENDERER_CRASHED', + '开发浏览器页面进程已退出。', + true, + record.generation, + ); + } + if (record.state !== 'attached' || !record.view.webContents.debugger.isAttached()) { + throw new AgentBrowserFault( + 'DEBUGGER_BUSY', + '开发浏览器调试器尚未就绪。', + true, + record.generation, + ); + } + return record; + } + + private requirePresented(projectPath: string): BrowserRecord { + const record = this.requireRecord(projectPath); + if (!record.visible || !record.bounds) { + throw new AgentBrowserFault( + 'VIEWPORT_NOT_READY', + '开发浏览器已收起,智能体调试已暂停。', + true, + record.generation, + ); + } + return record; + } + + private assertProject(record: BrowserRecord, projectPath: string): void { + if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) { + throw new AgentBrowserFault( + 'PROJECT_MISMATCH', + '智能体只能访问当前项目的开发浏览器。', + false, + record.generation, + ); + } + } + + private assertAvailable(): void { + if (this.disposed) { + throw new AgentBrowserFault('CLOSED', '开发浏览器模块已关闭。', false); + } + } + + private async closeInternal(expected?: BrowserRecord): Promise { + const record = expected ?? this.record; + if (!record) { + this.notifyEventWaiters(); + return; + } + if (expected && this.record !== expected) return; + record.state = 'closing'; + this.removeListeners(record); + this.record = null; + const interrupted = new AgentBrowserFault( + 'CLOSED', + '开发浏览器已关闭。', + true, + record.generation, + 'unknown', + ); + for (const interrupt of record.interruptWaiters) interrupt(interrupted); + record.interruptWaiters.clear(); + try { + if (record.view.webContents.debugger.isAttached()) { + record.view.webContents.debugger.detach(); + } + } catch { + // The renderer may already be gone. + } + try { + record.view.setVisible(false); + } catch { + // Native view teardown is best effort during app shutdown. + } + this.adapter.unmount(record.view); + this.adapter.destroy(record.view); + record.childSessions.clear(); + record.ioHandles.clear(); + record.eventBuffer.clear(); + this.payloadStore.clear(); + this.notifyEventWaiters(); + } + + private addWebContentsListener( + record: BrowserRecord, + event: string, + listener: PortListener, + ): void { + record.view.webContents.on(event, listener); + record.webContentsListeners.push({ event, listener }); + } + + private addDebuggerListener( + record: BrowserRecord, + event: 'message' | 'detach', + listener: PortListener, + ): void { + record.view.webContents.debugger.on(event, listener); + record.debuggerListeners.push({ event, listener }); + } + + private removeWebContentsListener( + record: BrowserRecord, + event: string, + listener: PortListener, + ): void { + record.view.webContents.removeListener(event, listener); + const index = record.webContentsListeners.findIndex( + (entry) => entry.event === event && entry.listener === listener, + ); + if (index >= 0) record.webContentsListeners.splice(index, 1); + } + + private removeListeners(record: BrowserRecord): void { + for (const { event, listener } of record.webContentsListeners) { + record.view.webContents.removeListener(event, listener); + } + for (const { event, listener } of record.debuggerListeners) { + record.view.webContents.debugger.removeListener(event, listener); + } + record.webContentsListeners.length = 0; + record.debuggerListeners.length = 0; + } + + private serialize(operation: () => Promise): Promise { + const epoch = this.queueEpoch; + const barrier = this.lifecycleBarrier; + let resolveCaller: (value: T) => void; + let rejectCaller: (reason: unknown) => void; + let callerSettled = false; + const caller = new Promise((resolvePromise, rejectPromise) => { + resolveCaller = resolvePromise; + rejectCaller = rejectPromise; + }); + const cancelCaller = (fault: AgentBrowserFault) => { + if (callerSettled) return; + callerSettled = true; + rejectCaller(fault); + }; + this.commandCancellers.add(cancelCaller); + + const execution = this.commandTail.then(async () => { + try { + await barrier; + if (callerSettled) return; + if (epoch !== this.queueEpoch) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器操作已被新的生命周期取代。', + true, + this.record?.generation, + 'unknown', + ); + } + const value = await operation(); + if (!callerSettled) { + callerSettled = true; + this.commandCancellers.delete(cancelCaller); + resolveCaller(value); + } + } catch (error) { + if (!callerSettled) { + callerSettled = true; + this.commandCancellers.delete(cancelCaller); + rejectCaller(error); + } + } + }); + this.commandTail = execution.then( + () => undefined, + () => undefined, + ); + return caller; + } + + private serializeWithTimeout( + operation: () => Promise, + timeoutMs: number, + ): Promise { + const epoch = this.queueEpoch; + const barrier = this.lifecycleBarrier; + let resolveCaller: (value: T) => void; + let rejectCaller: (reason: unknown) => void; + let callerSettled = false; + const generation = this.record?.generation; + let timer: ReturnType | undefined; + const caller = new Promise((resolvePromise, rejectPromise) => { + resolveCaller = resolvePromise; + rejectCaller = rejectPromise; + }); + const cancelCaller = (fault: AgentBrowserFault) => { + if (callerSettled) return; + callerSettled = true; + if (timer) clearTimeout(timer); + rejectCaller(fault); + }; + this.commandCancellers.add(cancelCaller); + timer = setTimeout(() => { + if (callerSettled) return; + callerSettled = true; + this.commandCancellers.delete(cancelCaller); + rejectCaller(new AgentBrowserFault( + 'CDP_TIMEOUT', + `CDP 命令在 ${timeoutMs}ms 内未完成,执行结果未知。`, + true, + generation, + 'unknown', + )); + this.preemptCommands('CDP 命令超时,开发浏览器已关闭。'); + void this.closeInternal().catch(() => undefined); + }, timeoutMs); + + const execution = this.commandTail.then(async () => { + await barrier; + if (callerSettled || epoch !== this.queueEpoch) return; + try { + const value = await operation(); + if (!callerSettled) { + callerSettled = true; + this.commandCancellers.delete(cancelCaller); + if (timer) clearTimeout(timer); + resolveCaller(value); + } + } catch (error) { + if (!callerSettled) { + callerSettled = true; + this.commandCancellers.delete(cancelCaller); + if (timer) clearTimeout(timer); + rejectCaller(error); + } + } + }); + this.commandTail = execution.then( + () => undefined, + () => undefined, + ); + return caller; + } + + private preemptCommands(message: string): void { + this.queueEpoch += 1; + this.commandTail = Promise.resolve(); + const fault = new AgentBrowserFault( + 'CLOSED', + message, + true, + this.record?.generation, + 'unknown', + ); + const cancellers = [...this.commandCancellers]; + this.commandCancellers.clear(); + for (const cancel of cancellers) cancel(fault); + } + + private async runWhileActive( + record: BrowserRecord, + operation: Promise, + timeoutMs: number, + ): Promise { + if (record !== this.record) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器已关闭或切换。', + true, + record.generation, + 'unknown', + ); + } + + let settled = false; + let timer: ReturnType | undefined; + let interrupt: ((fault: AgentBrowserFault) => void) | undefined; + const result = new Promise((resolvePromise, rejectPromise) => { + interrupt = (fault) => { + if (settled) return; + settled = true; + rejectPromise(fault); + }; + record.interruptWaiters.add(interrupt); + timer = setTimeout(() => { + if (settled) return; + settled = true; + rejectPromise(new AgentBrowserFault( + 'CDP_TIMEOUT', + `开发浏览器操作在 ${timeoutMs}ms 内未完成。`, + true, + record.generation, + 'unknown', + )); + }, timeoutMs); + operation.then( + (value) => { + if (settled) return; + settled = true; + if (record !== this.record) { + rejectPromise(new AgentBrowserFault( + 'CLOSED', + '开发浏览器操作完成前页面已关闭或切换。', + true, + record.generation, + 'unknown', + )); + return; + } + resolvePromise(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + rejectPromise(error); + }, + ); + }); + + try { + return await result; + } finally { + if (timer) clearTimeout(timer); + if (interrupt) record.interruptWaiters.delete(interrupt); + } + } + + private async waitForEvent(waitMs: number, changed: () => boolean): Promise { + if (changed()) return; + let wake: (() => void) | undefined; + const eventPromise = new Promise((resolvePromise) => { + wake = resolvePromise; + this.eventWaiters.add(resolvePromise); + }); + if (changed()) wake?.(); + let timer: ReturnType | undefined; + try { + await Promise.race([ + eventPromise, + new Promise((resolvePromise) => { + timer = setTimeout(resolvePromise, waitMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + if (wake) this.eventWaiters.delete(wake); + } + } + + private notifyEventWaiters(): void { + const waiters = [...this.eventWaiters]; + this.eventWaiters.clear(); + for (const wake of waiters) wake(); + } +} + +function normalizeRequiredPath(projectPath: string): string { + if (!projectPath.trim()) { + throw new AgentBrowserFault('INVALID_REQUEST', '项目目录不能为空。', false); + } + return resolve(projectPath); +} + +function normalizePath(projectPath: string): string { + const normalized = resolve(projectPath); + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized; +} + +function samePath(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right); +} + +function normalizeUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new AgentBrowserFault('INVALID_URL', '网页地址格式无效。', false); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new AgentBrowserFault( + 'INVALID_URL', + '开发浏览器只允许打开 HTTP 或 HTTPS 地址。', + false, + ); + } + return url.toString(); +} + +function normalizeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds { + const normalized = { + x: Math.trunc(bounds.x), + y: Math.trunc(bounds.y), + width: Math.trunc(bounds.width), + height: Math.trunc(bounds.height), + }; + if ( + !Object.values(normalized).every(Number.isFinite) + || normalized.width < 1 + || normalized.height < 1 + ) { + throw new AgentBrowserFault( + 'VIEWPORT_NOT_READY', + '开发浏览器显示区域无效。', + true, + ); + } + return normalized; +} + +function normalizeTimeout(value?: number): number { + if (value === undefined) return DEFAULT_CDP_TIMEOUT_MS; + if (!Number.isFinite(value) || value < 1) { + throw new AgentBrowserFault('INVALID_REQUEST', 'CDP timeoutMs 无效。', false); + } + return Math.min(Math.trunc(value), MAX_CDP_TIMEOUT_MS); +} + +function normalizeNonNegativeInteger( + value: number | undefined, + fallback: number, + field: string, +): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0) { + throw new AgentBrowserFault('INVALID_REQUEST', `${field} 无效。`, false); + } + return Math.trunc(value); +} + +function normalizeIntegerRange( + value: number | undefined, + fallback: number, + min: number, + max: number, + field: string, +): number { + const normalized = normalizeNonNegativeInteger(value, fallback, field); + if (normalized < min) { + throw new AgentBrowserFault('INVALID_REQUEST', `${field} 无效。`, false); + } + return Math.min(normalized, max); +} + +function safeUrl(view: AgentBrowserViewPort, fallback: string): string { + try { + return view.webContents.getURL() || fallback; + } catch { + return fallback; + } +} + +function toFault( + error: unknown, + code: AgentBrowserErrorCode, + fallbackMessage: string, + retryable: boolean, + generation?: number, +): AgentBrowserFault { + if (error instanceof AgentBrowserFault) return error; + return new AgentBrowserFault( + code, + errorMessage(error, fallbackMessage), + retryable, + generation, + ); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +function isNavigationAbort(error: unknown): boolean { + if (isRecord(error) && error.code === -3) return true; + const message = error instanceof Error ? error.message : String(error ?? ''); + return message.includes('ERR_ABORTED'); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function collectStringFields(value: unknown, field: string, output: Set): void { + if (Array.isArray(value)) { + for (const item of value) collectStringFields(item, field, output); + return; + } + if (!isRecord(value)) return; + for (const [key, nested] of Object.entries(value)) { + if (key === field && typeof nested === 'string') output.add(nested); + else collectStringFields(nested, field, output); + } +} diff --git a/electron/agent-browser/payload-store.ts b/electron/agent-browser/payload-store.ts new file mode 100644 index 0000000..0883b10 --- /dev/null +++ b/electron/agent-browser/payload-store.ts @@ -0,0 +1,167 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentBrowserPayloadChunk, + AgentBrowserPayloadRef, +} from '../../shared/agent-browser'; +import { AgentBrowserFault } from './fault'; + +type PayloadContentType = AgentBrowserPayloadRef['contentType']; + +interface PayloadEntry { + data: Buffer; + contentType: PayloadContentType; + expiresAt: number; +} + +export interface PayloadStoreOptions { + maxBytes?: number; + maxEntryBytes?: number; + ttlMs?: number; + now?: () => number; +} + +const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; +const DEFAULT_MAX_ENTRY_BYTES = 32 * 1024 * 1024; +const DEFAULT_TTL_MS = 60_000; +const DEFAULT_CHUNK_BYTES = 256 * 1024; +const MAX_CHUNK_BYTES = 1024 * 1024; + +export class AgentBrowserPayloadStore { + private readonly entries = new Map(); + private readonly maxBytes: number; + private readonly maxEntryBytes: number; + private readonly ttlMs: number; + private readonly now: () => number; + private totalBytes = 0; + + constructor(options: PayloadStoreOptions = {}) { + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES; + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.now = options.now ?? Date.now; + } + + putJson(value: unknown): AgentBrowserPayloadRef { + let encoded: Buffer; + try { + encoded = Buffer.from(JSON.stringify(value) ?? 'null'); + } catch { + throw new AgentBrowserFault( + 'CDP_PROTOCOL_ERROR', + 'CDP 返回了无法序列化的数据。', + false, + ); + } + return this.put(encoded, 'application/json'); + } + + put(data: Buffer, contentType: PayloadContentType): AgentBrowserPayloadRef { + this.purgeExpired(); + if (data.byteLength > this.maxEntryBytes || data.byteLength > this.maxBytes) { + throw new AgentBrowserFault( + 'PAYLOAD_TOO_LARGE', + `CDP 数据超过 ${this.maxEntryBytes} 字节的单项上限。`, + false, + ); + } + + while (this.totalBytes + data.byteLength > this.maxBytes) { + const oldest = this.entries.keys().next().value as string | undefined; + if (!oldest) break; + this.delete(oldest); + } + + const handle = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + this.entries.set(handle, { + data: Buffer.from(data), + contentType, + expiresAt, + }); + this.totalBytes += data.byteLength; + return { + kind: 'payload', + handle, + byteLength: data.byteLength, + contentType, + expiresAt: new Date(expiresAt).toISOString(), + }; + } + + read(handle: string, offset = 0, maxBytes = DEFAULT_CHUNK_BYTES): AgentBrowserPayloadChunk { + this.purgeExpired(); + const entry = this.entries.get(handle); + if (!entry) { + throw new AgentBrowserFault( + 'PAYLOAD_NOT_FOUND', + 'CDP 数据已过期或不存在。', + false, + ); + } + if (!Number.isInteger(offset) || offset < 0 || offset > entry.data.byteLength) { + throw new AgentBrowserFault('INVALID_REQUEST', 'Payload offset 无效。', false); + } + if (!Number.isInteger(maxBytes) || maxBytes < 1) { + throw new AgentBrowserFault('INVALID_REQUEST', 'Payload maxBytes 无效。', false); + } + + const requestedBytes = Math.min( + maxBytes, + MAX_CHUNK_BYTES, + ); + let nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength); + const isText = entry.contentType !== 'application/octet-stream'; + if (isText && offset > 0 && (entry.data[offset] & 0xc0) === 0x80) { + throw new AgentBrowserFault( + 'INVALID_REQUEST', + '文本 Payload offset 必须位于 UTF-8 字符边界。', + false, + ); + } + if (isText && nextOffset < entry.data.byteLength) { + while (nextOffset > offset && (entry.data[nextOffset] & 0xc0) === 0x80) { + nextOffset -= 1; + } + if (nextOffset === offset) { + nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength); + while ( + nextOffset < entry.data.byteLength + && (entry.data[nextOffset] & 0xc0) === 0x80 + ) { + nextOffset += 1; + } + } + } + + const chunk = entry.data.subarray(offset, nextOffset); + return { + handle, + offset, + nextOffset, + byteLength: entry.data.byteLength, + contentType: entry.contentType, + data: isText ? chunk.toString('utf8') : chunk.toString('base64'), + encoding: isText ? 'utf8' : 'base64', + done: nextOffset >= entry.data.byteLength, + }; + } + + delete(handle: string): void { + const entry = this.entries.get(handle); + if (!entry) return; + this.entries.delete(handle); + this.totalBytes -= entry.data.byteLength; + } + + clear(): void { + this.entries.clear(); + this.totalBytes = 0; + } + + private purgeExpired(): void { + const now = this.now(); + for (const [handle, entry] of this.entries) { + if (entry.expiresAt <= now) this.delete(handle); + } + } +} diff --git a/electron/api/context.ts b/electron/api/context.ts index aa3a3de..ff3017e 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -4,14 +4,66 @@ import type { OpencodeProjectStore } from '../opencode/project-store'; import type { HostEventBus } from './event-bus'; import type { createWorksCloudDeployment } from '../services/works-cloud-deployment'; import type { LocalImageWorkspace } from '../image-workspace/local-workspace'; +import type { + AgentBrowserBounds, + AgentBrowserCdpEventPage, + AgentBrowserCdpResult, + AgentBrowserPayloadChunk, + AgentBrowserSnapshot, +} from '../../shared/agent-browser'; export type WorksCloudDeploymentCoordinator = ReturnType; +export interface AgentBrowserService { + getSnapshot(projectPath?: string): Promise | AgentBrowserSnapshot; + open(input: { + projectId: string; + projectPath: string; + url: string; + bounds?: AgentBrowserBounds; + visible?: boolean; + }): Promise; + present(input: { + projectPath: string; + visible: boolean; + bounds?: AgentBrowserBounds; + }): Promise; + navigate(input: { + projectPath: string; + action: 'url' | 'back' | 'forward' | 'reload'; + url?: string; + }): Promise; + sendCdp(input: { + projectPath: string; + method: string; + params?: Record; + sessionRef?: string; + timeoutMs?: number; + }): Promise; + readEvents(input: { + projectPath: string; + after?: number; + methods?: string[]; + limit?: number; + waitMs?: number; + }): Promise; + readPayload(input: { + projectPath: string; + handle: string; + offset?: number; + maxBytes?: number; + }): Promise; + close(projectPath?: string): Promise; + resetProfile(projectPath: string): Promise; + dispose(): Promise; +} + export interface HostApiContext { opencodeManager: OpencodeManager; opencodeProjectStore: OpencodeProjectStore; eventBus: HostEventBus; mainWindow: BrowserWindow | null; + agentBrowser?: AgentBrowserService; worksCloudDeployment?: WorksCloudDeploymentCoordinator; imageWorkspace?: LocalImageWorkspace; } diff --git a/electron/api/renderer-capability.ts b/electron/api/renderer-capability.ts new file mode 100644 index 0000000..41234ae --- /dev/null +++ b/electron/api/renderer-capability.ts @@ -0,0 +1,21 @@ +import { randomBytes } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; + +export const RENDERER_CAPABILITY_HEADER = 'x-niancode-renderer-capability'; + +let rendererCapability = ''; + +export function rotateRendererCapability(): void { + rendererCapability = randomBytes(32).toString('hex'); +} + +export function getRendererCapability(): string { + return rendererCapability; +} + +export function hasRendererCapability(req: IncomingMessage): boolean { + const value = req.headers[RENDERER_CAPABILITY_HEADER]; + return typeof value === 'string' + && rendererCapability.length > 0 + && value === rendererCapability; +} diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts new file mode 100644 index 0000000..2700b41 --- /dev/null +++ b/electron/api/routes/agent-browser.ts @@ -0,0 +1,370 @@ +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; + } +} diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 729c4dc..6888c2a 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -585,6 +585,24 @@ async function findProjectById(ctx: HostApiContext, projectId: string) { return projects.find((project) => project.id === projectId) ?? null; } +async function closeAgentBrowserForProject( + ctx: HostApiContext, + projectPath: string, +): Promise { + try { + await ctx.agentBrowser?.close(projectPath); + } catch (error) { + if ( + error + && typeof error === 'object' + && (error as { code?: unknown }).code === 'PROJECT_MISMATCH' + ) { + return; + } + throw error; + } +} + type ProjectActivationCheck = | { ok: true } | { ok: false; status: Exclude | 'incomplete'; error: string }; @@ -1161,7 +1179,14 @@ export async function handleOpencodeRoutes( try { const body = await parseJsonBody<{ projectId?: string }>(req); if (!body.projectId) throw new Error('Missing project id'); + const activeProject = await ctx.opencodeProjectStore.getActiveProject(); + if (activeProject?.id === body.projectId) { + await closeAgentBrowserForProject(ctx, activeProject.path); + } await ctx.opencodeProjectStore.removeProject(body.projectId); + if (activeProject?.id === body.projectId) { + await closeAgentBrowserForProject(ctx, activeProject.path); + } await sendProjectSnapshot(res, ctx, { success: true, removedProjectId: body.projectId }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -1193,7 +1218,14 @@ export async function handleOpencodeRoutes( sendProjectActivationFailure(res, projectCheck); return true; } + const activeProject = await ctx.opencodeProjectStore.getActiveProject(); + if (activeProject?.id !== body.projectId && activeProject) { + await closeAgentBrowserForProject(ctx, activeProject.path); + } const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId); + if (activeProject?.id !== body.projectId && activeProject) { + await closeAgentBrowserForProject(ctx, activeProject.path); + } await sendProjectSnapshot(res, ctx, { success: true, project }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/api/server.ts b/electron/api/server.ts index 6f86725..b10e14f 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -16,7 +16,9 @@ import { handleLogRoutes } from './routes/logs'; import { handleUsageRoutes } from './routes/usage'; import { handleFileRoutes } from './routes/files'; import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; +import { handleAgentBrowserRoutes } from './routes/agent-browser'; import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils'; +import { rotateRendererCapability } from './renderer-capability'; type RouteHandler = ( req: IncomingMessage, @@ -31,6 +33,7 @@ const coreRouteHandlers: RouteHandler[] = [ handleAuthRoutes, handleImageWorkspaceRoutes, handleWorksRoutes, + handleAgentBrowserRoutes, handleUserSyncRoutes, handleOpencodeRoutes, handleSettingsRoutes, @@ -62,6 +65,7 @@ export function getHostApiToken(): string { export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE_HOST_API')): Server { // Generate a cryptographically random token for this session. hostApiToken = randomBytes(32).toString('hex'); + rotateRendererCapability(); const server = createServer(async (req, res) => { try { diff --git a/electron/main/index.ts b/electron/main/index.ts index 159e716..16b9db6 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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 | null = null; let worksCloudDeployment: ReturnType | 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 { // 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 { 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 { diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index 2da5f11..40e364e 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -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 = { ...(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) { diff --git a/electron/opencode/manager.ts b/electron/opencode/manager.ts index 58d7a27..036e021 100644 --- a/electron/opencode/manager.ts +++ b/electron/opencode/manager.ts @@ -8,6 +8,7 @@ import { type SpawnOptionsWithoutStdio, } from 'node:child_process'; import { + ensureBundledAgentBrowserPlugin, ensureBundledCourseSkills, ensureBundledSuperpowersPlugin, getManagedOpencodeConfigDir, @@ -163,6 +164,7 @@ export interface OpencodeManagerOptions { userDataDir?: string; bundledSuperpowersDir?: string; bundledCourseSkillsDir?: string; + bundledAgentBrowserPluginPath?: string; pythonRuntime?: PythonRuntime; spawn?: SpawnFn; configProvider?: ConfigProvider; @@ -580,6 +582,10 @@ export class OpencodeManager extends EventEmitter { managedConfigDir, sourceDir: this.options.bundledCourseSkillsDir, }); + ensureBundledAgentBrowserPlugin({ + managedConfigDir, + sourcePath: this.options.bundledAgentBrowserPluginPath, + }); const environment = { ...runtimeEnv, OPENCODE_CONFIG_DIR: managedConfigDir, diff --git a/electron/opencode/superpowers.ts b/electron/opencode/superpowers.ts index aa77d26..5a79b80 100644 --- a/electron/opencode/superpowers.ts +++ b/electron/opencode/superpowers.ts @@ -34,6 +34,7 @@ const SUPERPOWERS_REPO_DIR = 'superpowers'; const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles'; const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json'; export const BUNDLED_COURSE_SKILL_IDS = [ + 'agent-browser', 'deploy-publish-check', 'designer-design-spec', 'dev-build-test', @@ -63,6 +64,16 @@ export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput : join(input.appPath, '.opencode', 'skills'); } +export function resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string { + return join( + resolveBundledCourseSkillsDir(input), + 'agent-browser', + '.opencode', + 'plugins', + 'niancode-agent-browser.js', + ); +} + export function getManagedOpencodeConfigDir(userDataDir: string): string { return join(userDataDir, 'opencode', 'niancode-config'); } @@ -266,3 +277,15 @@ export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOpti return true; } + +export function ensureBundledAgentBrowserPlugin(options: { + managedConfigDir: string; + sourcePath?: string; +}): boolean { + const sourcePath = options.sourcePath?.trim(); + if (!sourcePath || !existsSync(sourcePath)) return false; + const targetPluginsDir = join(options.managedConfigDir, 'plugins'); + mkdirSync(targetPluginsDir, { recursive: true }); + cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true }); + return true; +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 9f9d527..c851c11 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -78,6 +78,8 @@ const validEventChannels = [ 'oauth:code', 'oauth:success', 'oauth:error', + 'agent-browser:show', + 'agent-browser:state', ]; /** diff --git a/package.json b/package.json index 3ba26f7..02d8c79 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.7", "@larksuiteoapi/node-sdk": "^1.61.1", + "@opencode-ai/plugin": "1.18.9", "@playwright/test": "^1.56.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac99924..dce8a2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + isbinaryfile: ^5.0.0 + importers: .: @@ -108,6 +111,9 @@ importers: '@larksuiteoapi/node-sdk': specifier: ^1.61.1 version: 1.62.0 + '@opencode-ai/plugin': + specifier: 1.18.9 + version: 1.18.9 '@playwright/test': specifier: ^1.56.1 version: 1.59.0 @@ -329,6 +335,10 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1004,6 +1014,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/canvas-android-arm64@0.1.100': resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} engines: {node: '>= 10'} @@ -1109,6 +1149,23 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@opencode-ai/plugin@1.18.9': + resolution: {integrity: sha512-0kFX9Usj+3N+WupIe9VnEdDNzMNbW4/C5GeIzdj02/t5kQoXsNrFpW3Br9aABebazcaYsQEWdlaLV0zQISy3OA==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.9': + resolution: {integrity: sha512-oDJSmsmiGW+3lNLmZYj3EpUkpiT3ITZBKffH3mrmu2KMJXlkxQ/Nvv7jqPffSM7o8lCdBZS/aCE+2GkA3/92gQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2694,6 +2751,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2875,6 +2935,10 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2929,6 +2993,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3231,6 +3298,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3289,10 +3360,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - isbinaryfile@4.0.10: - resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} - engines: {node: '>= 8.0.0'} - isbinaryfile@5.0.7: resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} engines: {node: '>= 18.0.0'} @@ -3353,6 +3420,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3377,6 +3447,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -3741,6 +3814,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3775,6 +3858,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@11.5.0: resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4133,6 +4220,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qrcode-terminal@0.12.0: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true @@ -4641,6 +4731,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4801,6 +4895,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -5020,6 +5118,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5038,6 +5141,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -5079,6 +5185,10 @@ snapshots: dependencies: zod: 4.3.6 + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@5.0.1': @@ -5353,7 +5463,7 @@ snapshots: compare-version: 0.1.2 debug: 4.4.3 fs-extra: 10.1.0 - isbinaryfile: 4.0.10 + isbinaryfile: 5.0.7 minimist: 1.2.8 plist: 3.1.0 transitivePeerDependencies: @@ -5754,6 +5864,24 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -5838,6 +5966,17 @@ snapshots: dependencies: semver: 7.7.4 + '@opencode-ai/plugin@1.18.9': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.18.9 + effect: 4.0.0-beta.83 + zod: 4.1.8 + + '@opencode-ai/sdk@1.18.9': + dependencies: + cross-spawn: 7.0.6 + '@pkgjs/parseargs@0.11.0': optional: true @@ -7425,6 +7564,19 @@ snapshots: eastasianwidth@0.2.0: {} + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -7687,6 +7839,10 @@ snapshots: extsprintf@1.4.1: optional: true + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -7739,6 +7895,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way-ts@0.1.6: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -8116,6 +8274,8 @@ snapshots: inherits@2.0.4: {} + ini@7.0.0: {} + inline-style-parser@0.2.7: {} ip-address@10.1.0: {} @@ -8157,8 +8317,6 @@ snapshots: is-unicode-supported@0.1.0: {} - isbinaryfile@4.0.10: {} - isbinaryfile@5.0.7: {} isexe@2.0.0: {} @@ -8224,6 +8382,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -8249,6 +8409,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + lazy-val@1.0.5: {} levn@0.4.1: @@ -8829,6 +8991,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -8858,6 +9038,11 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@11.5.0: dependencies: env-paths: 2.2.1 @@ -9168,6 +9353,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@8.4.2: {} + qrcode-terminal@0.12.0: {} qs@6.15.0: @@ -9815,6 +10002,8 @@ snapshots: dependencies: is-number: 7.0.0 + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.27 @@ -9965,6 +10154,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.1: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -10130,6 +10321,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -10148,6 +10341,8 @@ snapshots: dependencies: zod: 4.3.6 + zod@4.1.8: {} + zod@4.3.6: {} zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): diff --git a/shared/agent-browser.ts b/shared/agent-browser.ts new file mode 100644 index 0000000..3427d49 --- /dev/null +++ b/shared/agent-browser.ts @@ -0,0 +1,107 @@ +export type AgentBrowserState = + | 'closed' + | 'opening' + | 'attaching' + | 'attached' + | 'suspended_devtools' + | 'detached_fault' + | 'crashed' + | 'closing'; + +export interface AgentBrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface AgentBrowserPayloadRef { + kind: 'payload'; + handle: string; + byteLength: number; + contentType: 'application/json' | 'text/plain' | 'application/octet-stream'; + expiresAt: string; +} + +export interface AgentBrowserSnapshot { + browserId: string | null; + projectId: string | null; + projectPath: string | null; + state: AgentBrowserState; + generation: number; + url: string; + title: string; + visible: boolean; + bounds: AgentBrowserBounds | null; + canGoBack: boolean; + canGoForward: boolean; + eventCursor: number; + error?: { + code: AgentBrowserErrorCode; + message: string; + }; +} + +export interface AgentBrowserCdpEvent { + sequence: number; + generation: number; + timestamp: number; + method: string; + params?: unknown; + payload?: AgentBrowserPayloadRef; + sessionRef?: string; +} + +export interface AgentBrowserCdpEventPage { + events: AgentBrowserCdpEvent[]; + nextCursor: number; + hasMore: boolean; + gap?: { + reason: 'evicted' | 'debugger-detached' | 'view-recreated'; + oldestAvailable: number; + }; +} + +export type AgentBrowserCdpResult = + | { kind: 'inline'; value: unknown } + | AgentBrowserPayloadRef; + +export interface AgentBrowserPayloadChunk { + handle: string; + offset: number; + nextOffset: number; + byteLength: number; + contentType: AgentBrowserPayloadRef['contentType']; + data: string; + encoding: 'utf8' | 'base64'; + done: boolean; +} + +export type AgentBrowserErrorCode = + | 'PROJECT_NOT_ACTIVE' + | 'PROJECT_MISMATCH' + | 'BROWSER_NOT_OPEN' + | 'VIEWPORT_NOT_READY' + | 'DEBUGGER_BUSY' + | 'ATTACH_FAILED' + | 'DEVTOOLS_CONFLICT' + | 'CDP_METHOD_BLOCKED' + | 'TARGET_DENIED' + | 'CDP_TIMEOUT' + | 'CDP_PROTOCOL_ERROR' + | 'CURSOR_EXPIRED' + | 'PAYLOAD_NOT_FOUND' + | 'PAYLOAD_TOO_LARGE' + | 'TARGET_GONE' + | 'RENDERER_CRASHED' + | 'INVALID_URL' + | 'INVALID_REQUEST' + | 'CLOSED'; + +export interface AgentBrowserFaultShape { + code: AgentBrowserErrorCode; + message: string; + retryable: boolean; + generation?: number; + outcome?: 'unknown'; +} diff --git a/src/lib/agent-browser.ts b/src/lib/agent-browser.ts new file mode 100644 index 0000000..ea5f33b --- /dev/null +++ b/src/lib/agent-browser.ts @@ -0,0 +1,409 @@ +import { hostApiFetch } from '@/lib/host-api'; +import type { + AgentBrowserBounds, + AgentBrowserCdpEvent, + AgentBrowserCdpEventPage, + AgentBrowserCdpResult, + AgentBrowserPayloadChunk, + AgentBrowserSnapshot, +} from '../../shared/agent-browser'; + +type BrowserEnvelope = { + success: boolean; + browser: AgentBrowserSnapshot; + error?: string; +}; + +type EventPageEnvelope = { + success: boolean; + page: AgentBrowserCdpEventPage; + error?: string; +}; + +type CdpResultEnvelope = { + success: boolean; + result: AgentBrowserCdpResult; + error?: string; +}; + +type PayloadEnvelope = { + success: boolean; + chunk: AgentBrowserPayloadChunk; + error?: string; +}; + +export type AgentBrowserNavigateAction = 'url' | 'back' | 'forward' | 'reload'; + +export interface AgentBrowserConsoleEntry { + id: number; + level: string; + text: string; + timestamp: number; + source?: string; + url?: string; +} + +export interface AgentBrowserNetworkEntry { + requestId: string; + sequence: number; + method: string; + url: string; + resourceType?: string; + status?: number; + statusText?: string; + mimeType?: string; + durationMs?: number; + encodedDataLength?: number; + errorText?: string; +} + +export interface AgentBrowserDiagnostics { + console: AgentBrowserConsoleEntry[]; + network: AgentBrowserNetworkEntry[]; +} + +function jsonBody(body: Record): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} + +function requireSuccess( + envelope: T, +): T { + if (!envelope.success) { + throw new Error(envelope.error || '开发浏览器请求失败'); + } + return envelope; +} + +export async function getAgentBrowserState( + projectPath: string, +): Promise { + const query = new URLSearchParams({ project_path: projectPath }); + const response = await hostApiFetch( + `/api/agent-browser/state?${query.toString()}`, + ); + return requireSuccess(response).browser; +} + +export async function openAgentBrowser(input: { + projectPath: string; + url: string; + bounds?: AgentBrowserBounds; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/open', + jsonBody({ + project_path: input.projectPath, + url: input.url, + visible: true, + ...(input.bounds ? { bounds: input.bounds } : {}), + }), + ); + return requireSuccess(response).browser; +} + +export async function presentAgentBrowser(input: { + projectPath: string; + visible: boolean; + bounds?: AgentBrowserBounds; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/present', + jsonBody({ + project_path: input.projectPath, + visible: input.visible, + ...(input.bounds ? { bounds: input.bounds } : {}), + }), + ); + return requireSuccess(response).browser; +} + +export async function navigateAgentBrowser(input: { + projectPath: string; + action: AgentBrowserNavigateAction; + url?: string; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/navigate', + jsonBody({ + project_path: input.projectPath, + action: input.action, + ...(input.url ? { url: input.url } : {}), + }), + ); + return requireSuccess(response).browser; +} + +export async function sendAgentBrowserCdp(input: { + projectPath: string; + method: string; + params?: Record; + sessionRef?: string; + timeoutMs?: number; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/cdp/send', + jsonBody({ + project_path: input.projectPath, + method: input.method, + ...(input.params ? { params: input.params } : {}), + ...(input.sessionRef ? { session_ref: input.sessionRef } : {}), + ...(input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {}), + }), + ); + return requireSuccess(response).result; +} + +export async function readAgentBrowserEvents(input: { + projectPath: string; + after?: number; + methods?: string[]; + limit?: number; + waitMs?: number; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/cdp/events', + jsonBody({ + project_path: input.projectPath, + ...(input.after !== undefined ? { after: input.after } : {}), + ...(input.methods ? { methods: input.methods } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.waitMs !== undefined ? { wait_ms: input.waitMs } : {}), + }), + ); + return requireSuccess(response).page; +} + +export async function readAgentBrowserPayload(input: { + projectPath: string; + handle: string; + offset?: number; + maxBytes?: number; +}): Promise { + const response = await hostApiFetch( + '/api/agent-browser/payload/read', + jsonBody({ + project_path: input.projectPath, + handle: input.handle, + ...(input.offset !== undefined ? { offset: input.offset } : {}), + ...(input.maxBytes !== undefined ? { max_bytes: input.maxBytes } : {}), + }), + ); + return requireSuccess(response).chunk; +} + +export async function closeAgentBrowser( + projectPath: string, +): Promise { + const response = await hostApiFetch( + '/api/agent-browser/close', + jsonBody({ project_path: projectPath }), + ); + return requireSuccess(response).browser; +} + +export async function resetAgentBrowserProfile( + projectPath: string, +): Promise { + const response = await hostApiFetch( + '/api/agent-browser/reset-profile', + jsonBody({ project_path: projectPath }), + ); + return requireSuccess(response).browser; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function stringifyConsoleArgument(value: unknown): string { + const remoteObject = asRecord(value); + if (!remoteObject) return String(value ?? ''); + if ('value' in remoteObject) { + const primitive = remoteObject.value; + if (typeof primitive === 'string') return primitive; + try { + return JSON.stringify(primitive); + } catch { + return String(primitive); + } + } + return asString(remoteObject.description) + ?? asString(remoteObject.className) + ?? asString(remoteObject.type) + ?? ''; +} + +function consoleEntryFromEvent( + event: AgentBrowserCdpEvent, +): AgentBrowserConsoleEntry | null { + const params = asRecord(event.params); + if (!params) { + if ( + event.payload + && ( + event.method === 'Runtime.consoleAPICalled' + || event.method === 'Runtime.exceptionThrown' + || event.method === 'Log.entryAdded' + ) + ) { + return { + id: event.sequence, + level: 'info', + text: `大型 Console 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`, + timestamp: event.timestamp, + }; + } + return null; + } + + if (event.method === 'Runtime.consoleAPICalled') { + const args = Array.isArray(params.args) ? params.args : []; + return { + id: event.sequence, + level: asString(params.type) ?? 'log', + text: args.map(stringifyConsoleArgument).join(' '), + timestamp: event.timestamp, + }; + } + + if (event.method === 'Runtime.exceptionThrown') { + const details = asRecord(params.exceptionDetails); + const exception = asRecord(details?.exception); + const exceptionText = exception ? stringifyConsoleArgument(exception) : ''; + return { + id: event.sequence, + level: 'error', + text: asString(exception?.description) + ?? (exceptionText || undefined) + ?? asString(details?.text) + ?? '未捕获异常', + timestamp: event.timestamp, + url: asString(details?.url), + }; + } + + if (event.method === 'Log.entryAdded') { + const entry = asRecord(params.entry); + if (!entry) return null; + return { + id: event.sequence, + level: asString(entry.level) ?? 'log', + text: asString(entry.text) ?? '', + timestamp: event.timestamp, + source: asString(entry.source), + url: asString(entry.url), + }; + } + + return null; +} + +export function deriveAgentBrowserDiagnostics( + events: AgentBrowserCdpEvent[], +): AgentBrowserDiagnostics { + const consoleEntries: AgentBrowserConsoleEntry[] = []; + const requests = new Map(); + const requestOrder: string[] = []; + + for (const event of events) { + const consoleEntry = consoleEntryFromEvent(event); + if (consoleEntry) consoleEntries.push(consoleEntry); + + const params = asRecord(event.params); + const requestId = asString(params?.requestId); + if (!params || !requestId) { + if (event.payload && event.method.startsWith('Network.')) { + const payloadKey = `${event.sessionRef ?? 'root'}:payload:${event.sequence}`; + requestOrder.push(payloadKey); + requests.set(payloadKey, { + requestId: `payload:${event.sequence}`, + sequence: event.sequence, + method: event.method.slice('Network.'.length), + url: `大型 Network 事件(${event.payload.byteLength} 字节),智能体可按需读取完整内容`, + resourceType: 'payload', + startedAt: event.timestamp, + }); + } + continue; + } + const requestKey = `${event.sessionRef ?? 'root'}:${requestId}`; + + if (event.method === 'Network.requestWillBeSent') { + const request = asRecord(params.request); + if (!requests.has(requestKey)) requestOrder.push(requestKey); + requests.set(requestKey, { + requestId, + sequence: event.sequence, + method: asString(request?.method) ?? 'GET', + url: asString(request?.url) ?? '', + resourceType: asString(params.type), + startedAt: event.timestamp, + }); + continue; + } + + const current = requests.get(requestKey); + if (!current) continue; + + if (event.method === 'Network.responseReceived') { + const response = asRecord(params.response); + requests.set(requestKey, { + ...current, + url: asString(response?.url) ?? current.url, + resourceType: asString(params.type) ?? current.resourceType, + status: asNumber(response?.status), + statusText: asString(response?.statusText), + mimeType: asString(response?.mimeType), + }); + } else if (event.method === 'Network.loadingFinished') { + requests.set(requestKey, { + ...current, + durationMs: Math.max(0, event.timestamp - current.startedAt), + encodedDataLength: asNumber(params.encodedDataLength), + }); + } else if (event.method === 'Network.loadingFailed') { + requests.set(requestKey, { + ...current, + durationMs: Math.max(0, event.timestamp - current.startedAt), + errorText: asString(params.errorText) ?? '请求失败', + }); + } + } + + return { + console: consoleEntries, + network: requestOrder + .map((requestId) => requests.get(requestId)) + .filter((entry): entry is AgentBrowserNetworkEntry & { startedAt: number } => Boolean(entry)) + .map((entry) => ({ + requestId: entry.requestId, + sequence: entry.sequence, + method: entry.method, + url: entry.url, + ...(entry.resourceType ? { resourceType: entry.resourceType } : {}), + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.statusText ? { statusText: entry.statusText } : {}), + ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + ...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}), + ...(entry.encodedDataLength !== undefined + ? { encodedDataLength: entry.encodedDataLength } + : {}), + ...(entry.errorText ? { errorText: entry.errorText } : {}), + })), + }; +} diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index 7204e85..1a12c34 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -8,6 +8,8 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record = { 'oauth:code': 'oauth:code', 'oauth:success': 'oauth:success', 'oauth:error': 'oauth:error', + 'agent-browser:show': 'agent-browser:show', + 'agent-browser:state': 'agent-browser:state', }; function getEventSource(): EventSource { diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx new file mode 100644 index 0000000..b8b055d --- /dev/null +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -0,0 +1,680 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, +} from 'react'; +import { + ArrowLeft, + ArrowRight, + Bug, + Globe2, + Loader2, + PanelRightClose, + PanelRightOpen, + Power, + RefreshCw, + Trash2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + closeAgentBrowser, + deriveAgentBrowserDiagnostics, + getAgentBrowserState, + navigateAgentBrowser, + openAgentBrowser, + presentAgentBrowser, + readAgentBrowserEvents, +} from '@/lib/agent-browser'; +import { subscribeHostEvent } from '@/lib/host-events'; +import { cn } from '@/lib/utils'; +import type { + AgentBrowserBounds, + AgentBrowserCdpEvent, + AgentBrowserSnapshot, +} from '../../../shared/agent-browser'; + +type DiagnosticsTab = 'console' | 'network'; + +export interface AgentBrowserPanelProps { + projectId?: string | null; + projectPath: string | null; +} + +const DEFAULT_URL = 'http://localhost:5173'; +const EVENT_METHODS = [ + 'Runtime.consoleAPICalled', + 'Runtime.exceptionThrown', + 'Log.entryAdded', + 'Network.requestWillBeSent', + 'Network.responseReceived', + 'Network.loadingFinished', + 'Network.loadingFailed', +]; + +function normalizeAddress(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return DEFAULT_URL; + return /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; +} + +function readBounds(element: HTMLElement | null): AgentBrowserBounds | undefined { + if (!element) return undefined; + const rect = element.getBoundingClientRect(); + const bounds = { + x: Math.round(rect.left), + y: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + return bounds.width > 0 && bounds.height > 0 ? bounds : undefined; +} + +function hasModalOcclusion(): boolean { + return Array.from( + document.querySelectorAll('[role="dialog"], [role="alertdialog"]'), + ).some((element) => { + if (element.getAttribute('data-state') === 'closed' || element.hidden) return false; + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); +} + +function isBrowserActive(snapshot: AgentBrowserSnapshot | null): boolean { + return Boolean( + snapshot?.browserId + && snapshot.state !== 'closed' + && snapshot.state !== 'closing', + ); +} + +function statusPresentation(snapshot: AgentBrowserSnapshot | null): { + label: string; + className: string; +} { + if (snapshot?.state === 'attached') { + return { + label: 'AI 可调试', + className: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20', + }; + } + if (snapshot?.state === 'suspended_devtools') { + return { + label: '原生 DevTools 占用', + className: 'bg-amber-50 text-amber-700 ring-amber-600/20', + }; + } + if (snapshot?.state === 'opening' || snapshot?.state === 'attaching') { + return { + label: '正在连接', + className: 'bg-brand-soft text-brand ring-brand/20', + }; + } + if ( + snapshot?.state === 'crashed' + || snapshot?.state === 'detached_fault' + ) { + return { + label: '需要恢复', + className: 'bg-destructive/10 text-destructive ring-destructive/20', + }; + } + return { + label: '尚未开启', + className: 'bg-surface-subtle text-muted-foreground ring-border', + }; +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) + ? '--:--:--' + : date.toLocaleTimeString('zh-CN', { hour12: false }); +} + +function consoleTone(level: string): string { + if (level === 'error' || level === 'assert') return 'text-destructive'; + if (level === 'warning' || level === 'warn') return 'text-amber-700'; + return 'text-foreground'; +} + +function statusTone(status?: number): string { + if (status === undefined) return 'text-muted-foreground'; + if (status >= 500) return 'text-destructive'; + if (status >= 400) return 'text-amber-700'; + return 'text-emerald-700'; +} + +export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const [address, setAddress] = useState(DEFAULT_URL); + const [activeTab, setActiveTab] = useState('console'); + const [events, setEvents] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [occluded, setOccluded] = useState(false); + const viewportRef = useRef(null); + const addressInputRef = useRef(null); + const eventCursorRef = useRef(0); + + const browserActive = isBrowserActive(snapshot); + const status = statusPresentation(snapshot); + const diagnostics = useMemo( + () => deriveAgentBrowserDiagnostics(events), + [events], + ); + + useEffect(() => { + const updateOcclusion = () => setOccluded(hasModalOcclusion()); + updateOcclusion(); + const observer = new MutationObserver(updateOcclusion); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-state', 'hidden', 'style'], + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, []); + + const applySnapshot = useCallback((next: AgentBrowserSnapshot) => { + setSnapshot(next); + if ( + next.url + && document.activeElement !== addressInputRef.current + ) { + setAddress(next.url); + } + }, []); + + useEffect(() => { + if (!projectPath) return undefined; + const unsubscribeShow = subscribeHostEvent( + 'agent-browser:show', + (next) => { + if (projectId && next.projectId !== projectId) return; + applySnapshot(next); + setIsOpen(true); + }, + ); + const unsubscribeState = subscribeHostEvent( + 'agent-browser:state', + (next) => { + if (projectId && next.projectId && next.projectId !== projectId) return; + applySnapshot(next); + }, + ); + return () => { + unsubscribeShow(); + unsubscribeState(); + }; + }, [applySnapshot, projectId, projectPath]); + + useEffect(() => { + setSnapshot(null); + setEvents([]); + eventCursorRef.current = 0; + setError(null); + setIsOpen(false); + if (!projectPath) return undefined; + + let cancelled = false; + void getAgentBrowserState(projectPath) + .then((next) => { + if (cancelled || !isBrowserActive(next)) return; + applySnapshot(next); + setIsOpen(true); + }) + .catch(() => { + // A missing browser is the normal initial state. + }); + return () => { + cancelled = true; + }; + }, [applySnapshot, projectPath]); + + useEffect(() => { + if (!isOpen || !projectPath) return undefined; + let cancelled = false; + + const refresh = async () => { + try { + const next = await getAgentBrowserState(projectPath); + if (!cancelled) { + applySnapshot(next); + setError(null); + } + } catch (cause) { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : '无法读取开发浏览器状态'); + } + } + }; + + void refresh(); + const interval = window.setInterval(() => void refresh(), 2500); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [applySnapshot, isOpen, projectPath]); + + useEffect(() => { + eventCursorRef.current = 0; + setEvents([]); + }, [snapshot?.browserId, snapshot?.projectPath]); + + useEffect(() => { + if (!isOpen || !projectPath || snapshot?.state !== 'attached') { + return undefined; + } + let cancelled = false; + let inFlight = false; + + const poll = async () => { + if (inFlight) return; + inFlight = true; + try { + const page = await readAgentBrowserEvents({ + projectPath, + after: eventCursorRef.current, + methods: EVENT_METHODS, + limit: 200, + waitMs: 500, + }); + if (cancelled) return; + eventCursorRef.current = page.nextCursor; + setEvents((current) => { + const base = page.gap ? [] : current; + return [...base, ...page.events].slice(-1000); + }); + } catch { + // State polling presents attachment failures without turning the panel noisy. + } finally { + inFlight = false; + } + }; + + void poll(); + const interval = window.setInterval(() => void poll(), 750); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [isOpen, projectPath, snapshot?.state, snapshot?.generation]); + + useEffect(() => { + if (!isOpen || occluded || !projectPath || !browserActive) return undefined; + const viewport = viewportRef.current; + if (!viewport) return undefined; + let cancelled = false; + let timer: number | null = null; + let lastBounds = ''; + + const schedulePresentation = () => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = null; + const bounds = readBounds(viewport); + if (!bounds) return; + const key = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}`; + if (key === lastBounds) return; + lastBounds = key; + void presentAgentBrowser({ + projectPath, + visible: true, + bounds, + }).then((next) => { + if (!cancelled) applySnapshot(next); + }).catch((cause) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : '无法显示开发浏览器'); + } + }); + }, 50); + }; + + const observer = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(schedulePresentation); + observer?.observe(viewport); + window.addEventListener('resize', schedulePresentation); + schedulePresentation(); + + return () => { + cancelled = true; + if (timer !== null) window.clearTimeout(timer); + observer?.disconnect(); + window.removeEventListener('resize', schedulePresentation); + }; + }, [applySnapshot, browserActive, isOpen, occluded, projectPath]); + + useEffect(() => { + if (!projectPath || !browserActive) return undefined; + if (!isOpen || occluded) { + void presentAgentBrowser({ + projectPath, + visible: false, + }).catch(() => undefined); + return undefined; + } + return () => { + void presentAgentBrowser({ + projectPath, + visible: false, + }).catch(() => undefined); + }; + }, [browserActive, isOpen, occluded, projectPath]); + + const runBrowserAction = useCallback(async ( + action: () => Promise, + ) => { + setBusy(true); + setError(null); + try { + applySnapshot(await action()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '开发浏览器操作失败'); + } finally { + setBusy(false); + } + }, [applySnapshot]); + + const handleOpenAddress = useCallback((event?: FormEvent) => { + event?.preventDefault(); + if (!projectPath) return; + const url = normalizeAddress(address); + setAddress(url); + void runBrowserAction(() => browserActive + ? navigateAgentBrowser({ projectPath, action: 'url', url }) + : openAgentBrowser({ + projectPath, + url, + bounds: readBounds(viewportRef.current), + })); + }, [address, browserActive, projectPath, runBrowserAction]); + + const handleNavigate = useCallback(( + action: 'back' | 'forward' | 'reload', + ) => { + if (!projectPath || !browserActive) return; + void runBrowserAction( + () => navigateAgentBrowser({ projectPath, action }), + ); + }, [browserActive, projectPath, runBrowserAction]); + + const handlePower = useCallback(() => { + if (!projectPath) return; + if (browserActive) { + void runBrowserAction(() => closeAgentBrowser(projectPath)); + return; + } + const url = normalizeAddress(address); + setAddress(url); + void runBrowserAction(() => openAgentBrowser({ + projectPath, + url, + bounds: readBounds(viewportRef.current), + })); + }, [address, browserActive, projectPath, runBrowserAction]); + + if (!isOpen) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index 70e57c2..500dcae 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -18,6 +18,7 @@ import { ChatMessage } from './ChatMessage'; import { ComposerAttachmentCard } from './ComposerAttachmentCard'; import { ExecutionGraphCard } from './ExecutionGraphCard'; import { GameAssetBrowser } from './GameAssetBrowser'; +import { AgentBrowserPanel } from './AgentBrowserPanel'; import { OpencodeSessionDiffPreview } from './OpencodeSessionDiffPreview'; import { buildMessageTextWithComposerAttachments, @@ -3174,6 +3175,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro + {!compactLayout ? ( + + ) : null} ; + sessionRef?: string; + }> = []; + readonly attachVersions: string[] = []; + readonly responders = new Map Promise>(); + attached = false; + operationLog: string[] = []; + + attach(version: string): void { + this.operationLog.push(`attach:${version}`); + this.attachVersions.push(version); + this.attached = true; + } + + detach(): void { + this.operationLog.push('detach'); + this.attached = false; + } + + isAttached(): boolean { + return this.attached; + } + + async sendCommand( + method: string, + params?: Record, + sessionRef?: string, + ): Promise { + this.operationLog.push(`command:${method}`); + this.commands.push({ method, params, sessionRef }); + return await (this.responders.get(method)?.() ?? Promise.resolve({})); + } + + on(event: 'message' | 'detach', listener: PortListener): void { + this.events.on(event, listener); + } + + removeListener(event: 'message' | 'detach', listener: PortListener): void { + this.events.removeListener(event, listener); + } + + message(method: string, params: unknown, sessionRef?: string): void { + if (!this.attached) return; + this.events.emit('message', {}, method, params, sessionRef); + } + + detached(reason = 'replaced_with_devtools'): void { + this.attached = false; + this.events.emit('detach', {}, reason); + } +} + +class FakeNavigation implements AgentBrowserNavigationPort { + back = false; + forward = false; + goBackCalls = 0; + goForwardCalls = 0; + clearCalls = 0; + + canGoBack(): boolean { + return this.back; + } + + canGoForward(): boolean { + return this.forward; + } + + goBack(): void { + this.goBackCalls += 1; + } + + goForward(): void { + this.goForwardCalls += 1; + } + + clear(): void { + this.clearCalls += 1; + this.back = false; + this.forward = false; + } +} + +class FakeWebContents implements AgentBrowserWebContentsPort { + readonly events = new EventEmitter(); + readonly debugger = new FakeDebugger(); + readonly navigationHistory = new FakeNavigation(); + readonly loadCalls: string[] = []; + url = ''; + title = 'Student app'; + destroyed = false; + devToolsOpen = false; + reloadCalls = 0; + windowOpenDenied = false; + onLoad?: () => void; + loadHandler?: (url: string) => Promise; + + async loadURL(url: string): Promise { + this.debugger.operationLog.push(`load:${url}`); + this.loadCalls.push(url); + this.url = url; + this.onLoad?.(); + await this.loadHandler?.(url); + } + + getURL(): string { + return this.url; + } + + getTitle(): string { + return this.title; + } + + isDestroyed(): boolean { + return this.destroyed; + } + + isDevToolsOpened(): boolean { + return this.devToolsOpen; + } + + reload(): void { + this.reloadCalls += 1; + } + + denyWindowOpen(): void { + this.windowOpenDenied = true; + } + + on(event: string, listener: PortListener): void { + this.events.on(event, listener); + } + + removeListener(event: string, listener: PortListener): void { + this.events.removeListener(event, listener); + } + + emit(event: string, ...args: unknown[]): void { + this.events.emit(event, ...args); + } +} + +class FakeView implements AgentBrowserViewPort { + readonly webContents = new FakeWebContents(); + bounds: { x: number; y: number; width: number; height: number } | null = null; + visible = false; + + setBounds(bounds: { x: number; y: number; width: number; height: number }): void { + this.bounds = bounds; + } + + setVisible(visible: boolean): void { + this.visible = visible; + } +} + +class FakeAdapter implements AgentBrowserAdapter { + readonly views: FakeView[] = []; + readonly partitions: string[] = []; + readonly resetPartitions: string[] = []; + mounted = 0; + unmounted = 0; + destroyed = 0; + onCreate?: (view: FakeView) => void; + resetHandler?: (partition: string) => Promise; + + createView(partition: string): AgentBrowserViewPort { + const view = new FakeView(); + this.views.push(view); + this.partitions.push(partition); + this.onCreate?.(view); + return view; + } + + mount(): void { + this.mounted += 1; + } + + unmount(): void { + this.unmounted += 1; + } + + destroy(view: AgentBrowserViewPort): void { + this.destroyed += 1; + (view.webContents as FakeWebContents).destroyed = true; + } + + async resetPartition(partition: string): Promise { + this.resetPartitions.push(partition); + await this.resetHandler?.(partition); + } +} + +const projectPath = 'D:\\student\\clock'; + +async function openBrowser(adapter = new FakeAdapter()) { + const module = new AgentBrowserModule(adapter); + const opening = module.open({ + projectId: 'clock', + projectPath, + url: 'http://127.0.0.1:4173', + visible: true, + bounds: { x: 10, y: 20, width: 800, height: 600 }, + }); + const snapshot = await opening; + return { adapter, module, snapshot, view: adapter.views[0] }; +} + +describe('AgentBrowserModule', () => { + it('primes the renderer before attaching CDP and loading the shared page', async () => { + const adapter = new FakeAdapter(); + adapter.onCreate = (view) => { + view.webContents.debugger.responders.set('Runtime.enable', async () => { + if (view.webContents.getURL() !== 'about:blank') { + throw new Error('Renderer execution context is not ready.'); + } + return {}; + }); + }; + const { snapshot, view } = await openBrowser(adapter); + + expect(snapshot).toMatchObject({ + projectId: 'clock', + state: 'attached', + generation: 1, + visible: true, + bounds: { x: 10, y: 20, width: 800, height: 600 }, + }); + expect(adapter.partitions[0]).toMatch(/^persist:niancode-agent-browser:[a-f0-9]{32}$/); + expect(view.webContents.windowOpenDenied).toBe(true); + expect(view.webContents.navigationHistory.clearCalls).toBe(1); + expect(view.webContents.debugger.operationLog).toEqual([ + 'load:about:blank', + 'attach:1.3', + 'command:Runtime.enable', + 'command:Log.enable', + 'command:Network.enable', + 'command:Page.enable', + 'command:Target.setAutoAttach', + 'load:http://127.0.0.1:4173/', + ]); + }); + + it('finishes navigation when the main document is DOM-ready even if loadURL stays pending', async () => { + vi.useFakeTimers(); + try { + const adapter = new FakeAdapter(); + adapter.onCreate = (view) => { + view.webContents.loadHandler = async (url) => { + if (url === 'about:blank') return; + await new Promise(() => undefined); + }; + }; + const module = new AgentBrowserModule(adapter); + const opening = module.open({ + projectId: 'clock', + projectPath, + url: 'https://www.baidu.com', + bounds: { x: 0, y: 0, width: 800, height: 600 }, + }); + const openingResult = opening.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(0); + const view = adapter.views[0]; + expect(view.webContents.loadCalls).toContain('https://www.baidu.com/'); + view.webContents.emit( + 'did-fail-load', + {}, + -105, + 'ERR_NAME_NOT_RESOLVED', + 'https://optional.example.test/image.png', + false, + ); + view.webContents.emit('dom-ready'); + await vi.advanceTimersByTimeAsync(30_000); + + expect(await openingResult).toMatchObject({ + state: 'attached', + url: 'https://www.baidu.com/', + }); + expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); + expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores the previous DOM-ready load being aborted by the next navigation', async () => { + vi.useFakeTimers(); + const adapter = new FakeAdapter(); + const module = new AgentBrowserModule(adapter); + try { + adapter.onCreate = (view) => { + view.webContents.loadHandler = async (url) => { + if (url === 'about:blank') return; + await new Promise(() => undefined); + }; + }; + const opening = module.open({ + projectId: 'clock', + projectPath, + url: 'https://www.baidu.com', + bounds: { x: 0, y: 0, width: 800, height: 600 }, + }); + const openingResult = opening.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(0); + const view = adapter.views[0]; + view.webContents.emit('dom-ready'); + await vi.advanceTimersByTimeAsync(0); + expect(await openingResult).toMatchObject({ state: 'attached' }); + + const navigating = module.navigate({ + projectPath, + action: 'url', + url: 'https://example.com', + }); + const navigationResult = navigating.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + view.webContents.emit( + 'did-fail-load', + {}, + -3, + 'ERR_ABORTED', + 'https://www.baidu.com/', + true, + ); + view.webContents.emit('dom-ready'); + await vi.advanceTimersByTimeAsync(0); + + expect(await navigationResult).toMatchObject({ + state: 'attached', + url: 'https://example.com/', + }); + } finally { + await module.close(projectPath); + vi.useRealTimers(); + } + }); + + it('cleans temporary navigation listeners when navigation times out', async () => { + vi.useFakeTimers(); + const { module, view } = await openBrowser(); + try { + view.webContents.loadHandler = async () => + await new Promise(() => undefined); + const navigating = module.navigate({ + projectPath, + action: 'url', + url: 'https://slow.example.com', + }); + const navigationResult = navigating.catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(30_000); + + expect(await navigationResult).toMatchObject({ code: 'CDP_TIMEOUT' }); + expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); + expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); + } finally { + await module.close(projectPath); + vi.useRealTimers(); + } + }); + + it('rejects a main-frame load failure and cleans temporary listeners', async () => { + const { module, view } = await openBrowser(); + try { + view.webContents.loadHandler = async () => + await new Promise(() => undefined); + const navigating = module.navigate({ + projectPath, + action: 'url', + url: 'https://missing.example.com', + }); + const navigationResult = navigating.catch((error: unknown) => error); + await vi.waitFor(() => { + expect(view.webContents.events.listenerCount('did-fail-load')).toBe(1); + }); + view.webContents.emit( + 'did-fail-load', + {}, + -105, + 'ERR_NAME_NOT_RESOLVED', + 'https://missing.example.com/', + true, + ); + + expect(await navigationResult).toMatchObject({ + code: 'CDP_PROTOCOL_ERROR', + message: 'ERR_NAME_NOT_RESOLVED', + }); + expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); + expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); + } finally { + await module.close(projectPath); + } + }); + + it('captures console and network events emitted during the first load', async () => { + const adapter = new FakeAdapter(); + adapter.onCreate = (view) => { + view.webContents.onLoad = () => { + view.webContents.debugger.message('Runtime.consoleAPICalled', { + type: 'log', + args: [{ value: 'ready' }], + }); + view.webContents.debugger.message('Network.requestWillBeSent', { + requestId: 'request-1', + request: { url: 'http://localhost:3000/api/data' }, + }); + }; + }; + const module = new AgentBrowserModule(adapter); + await module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3000', + }); + await module.present({ + projectPath, + visible: true, + bounds: { x: 0, y: 0, width: 800, height: 600 }, + }); + + const page = await module.readEvents({ projectPath, after: 0 }); + expect(page.events.map((event) => event.method)).toEqual([ + 'Runtime.consoleAPICalled', + 'Network.requestWillBeSent', + ]); + expect(page.events.every((event) => event.generation === 1)).toBe(true); + }); + + it('keeps a view hidden until open/present receives current bounds', async () => { + const adapter = new FakeAdapter(); + const module = new AgentBrowserModule(adapter); + const first = await module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3000', + visible: true, + }); + expect(first).toMatchObject({ visible: false, bounds: null }); + expect(adapter.views[0].visible).toBe(false); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); + await expect(module.readEvents({ + projectPath, + after: 0, + })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); + + await expect(module.present({ + projectPath, + visible: true, + })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); + + await module.present({ + projectPath, + visible: true, + bounds: { x: 1, y: 2, width: 640, height: 480 }, + }); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + })).resolves.toMatchObject({ kind: 'inline' }); + await module.present({ projectPath, visible: false }); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); + const reopened = await module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3000', + visible: true, + }); + expect(reopened).toMatchObject({ + visible: false, + bounds: { x: 1, y: 2, width: 640, height: 480 }, + }); + expect(adapter.views[0].visible).toBe(false); + }); + + it('tears down the native view when initial navigation fails', async () => { + const adapter = new FakeAdapter(); + adapter.onCreate = (view) => { + view.webContents.loadHandler = async (url) => { + if (url !== 'about:blank') throw new Error('ERR_CONNECTION_REFUSED'); + }; + }; + const module = new AgentBrowserModule(adapter); + + await expect(module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3999', + bounds: { x: 0, y: 0, width: 640, height: 480 }, + })).rejects.toMatchObject({ code: 'CDP_PROTOCOL_ERROR' }); + expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + }); + + it('lets close preempt a never-settling initial navigation', async () => { + const adapter = new FakeAdapter(); + adapter.onCreate = (view) => { + view.webContents.loadHandler = async (url) => { + if (url !== 'about:blank') { + await new Promise(() => undefined); + } + }; + }; + const module = new AgentBrowserModule(adapter); + const opening = module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3000', + bounds: { x: 0, y: 0, width: 640, height: 480 }, + }); + const openingResult = opening.catch((error: unknown) => error); + await vi.waitFor(() => expect(adapter.views).toHaveLength(1)); + + await expect(module.close(projectPath)).resolves.toMatchObject({ state: 'closed' }); + expect(await openingResult).toMatchObject({ code: 'CLOSED' }); + expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); + }); + + it.each([ + 'Runtime.evaluate', + 'DOM.getDocument', + 'CSS.getComputedStyleForNode', + 'Network.getResponseBody', + 'Performance.getMetrics', + ])('allows page-scoped Full CDP command %s', async (method) => { + const { module, view } = await openBrowser(); + view.webContents.debugger.responders.set(method, async () => ({ method })); + + await expect(module.sendCdp({ projectPath, method })).resolves.toEqual({ + kind: 'inline', + value: { method }, + }); + }); + + it.each([ + ['Browser.close', 'CDP_METHOD_BLOCKED'], + ['Browser.setWindowBounds', 'CDP_METHOD_BLOCKED'], + ['Page.crash', 'CDP_METHOD_BLOCKED'], + ['Target.getTargets', 'TARGET_DENIED'], + ['SystemInfo.getInfo', 'CDP_METHOD_BLOCKED'], + ['Memory.forciblyPurgeJavaScriptMemory', 'CDP_METHOD_BLOCKED'], + ['Security.setIgnoreCertificateErrors', 'CDP_METHOD_BLOCKED'], + ['DOM.setFileInputFiles', 'CDP_METHOD_BLOCKED'], + ['Extensions.loadUnpacked', 'CDP_METHOD_BLOCKED'], + ['Tethering.bind', 'CDP_METHOD_BLOCKED'], + ['DeviceAccess.enable', 'CDP_METHOD_BLOCKED'], + ])('blocks host-affecting CDP command %s', async (method, code) => { + const { module } = await openBrowser(); + + await expect(module.sendCdp({ projectPath, method })).rejects.toMatchObject({ code }); + }); + + it.each([ + ['Page.navigate', { url: 'file:///C:/Windows/win.ini' }], + ['Network.loadNetworkResource', { url: 'file:///etc/passwd' }], + ['Fetch.continueRequest', { requestId: 'request-1', url: 'data:text/plain,secret' }], + ])('blocks non-web URLs in CDP command %s', async (method, params) => { + const { module } = await openBrowser(); + + await expect(module.sendCdp({ + projectPath, + method, + params, + })).rejects.toMatchObject({ code: 'CDP_METHOD_BLOCKED' }); + }); + + it('only accepts child sessions auto-attached from the current page', async () => { + const { module, view } = await openBrowser(); + + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + sessionRef: 'unknown-session', + })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); + + view.webContents.debugger.message('Target.attachedToTarget', { + sessionId: 'worker-session', + targetInfo: { type: 'worker' }, + }); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + params: { expression: 'self.location.href' }, + sessionRef: 'worker-session', + })).resolves.toMatchObject({ kind: 'inline' }); + + view.webContents.debugger.message('Target.detachedFromTarget', { + sessionId: 'worker-session', + }); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + sessionRef: 'worker-session', + })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); + }); + + it.each(['targetId', 'browserContextId'])( + 'rejects a foreign CDP scope supplied through params.%s', + async (field) => { + const { module } = await openBrowser(); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + params: { expression: '1', [field]: 'foreign-target' }, + })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); + }, + ); + + it('moves oversized events and command results into bounded payload handles', async () => { + const { module, view } = await openBrowser(); + const largeText = 'x'.repeat(70 * 1024); + view.webContents.debugger.message('Network.dataReceived', { data: largeText }); + + const page = await module.readEvents({ projectPath, after: 0 }); + expect(page.events[0]).toMatchObject({ + method: 'Network.dataReceived', + payload: { kind: 'payload', contentType: 'application/json' }, + }); + expect(page.events[0]).not.toHaveProperty('params'); + const eventPayload = page.events[0].payload; + expect(eventPayload).toBeDefined(); + const eventChunk = await module.readPayload({ + projectPath, + handle: eventPayload!.handle, + maxBytes: 1024 * 1024, + }); + expect(JSON.parse(eventChunk.data)).toEqual({ data: largeText }); + + view.webContents.debugger.responders.set('Network.getResponseBody', async () => ({ + body: largeText, + base64Encoded: false, + })); + const result = await module.sendCdp({ + projectPath, + method: 'Network.getResponseBody', + params: { requestId: 'request-1' }, + }); + expect(result).toMatchObject({ kind: 'payload' }); + if (result.kind !== 'payload') throw new Error('Expected a payload result.'); + const resultChunk = await module.readPayload({ + projectPath, + handle: result.handle, + maxBytes: 1024 * 1024, + }); + expect(JSON.parse(resultChunk.data)).toEqual({ + body: largeText, + base64Encoded: false, + }); + }); + + it('reports a debugger gap and reattaches with a new generation after DevTools closes', async () => { + const { module, view } = await openBrowser(); + view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'log' }); + const before = await module.readEvents({ projectPath, after: 0 }); + + view.webContents.devToolsOpen = true; + view.webContents.emit('devtools-opened'); + view.webContents.debugger.detached(); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + })).rejects.toMatchObject({ code: 'DEVTOOLS_CONFLICT' }); + + view.webContents.devToolsOpen = false; + view.webContents.emit('devtools-closed'); + await vi.waitFor(async () => { + const snapshot = await module.getSnapshot(projectPath); + expect(snapshot).toMatchObject({ state: 'attached', generation: 2 }); + }); + view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'info' }); + + const after = await module.readEvents({ + projectPath, + after: before.nextCursor, + }); + expect(after.gap?.reason).toBe('debugger-detached'); + expect(after.events[0]).toMatchObject({ + generation: 2, + method: 'Runtime.consoleAPICalled', + }); + expect(view.webContents.debugger.attachVersions).toEqual(['1.3', '1.3']); + }); + + it('keeps a late DevTools reattach from reviving a closed browser', async () => { + const { module, view } = await openBrowser(); + view.webContents.devToolsOpen = true; + view.webContents.emit('devtools-opened'); + view.webContents.debugger.detached(); + + let finishAttach: (() => void) | undefined; + view.webContents.debugger.responders.set( + 'Runtime.enable', + async () => await new Promise((resolvePromise) => { + finishAttach = resolvePromise; + }), + ); + view.webContents.devToolsOpen = false; + view.webContents.emit('devtools-closed'); + await vi.waitFor(() => { + expect(view.webContents.debugger.attachVersions).toEqual(['1.3', '1.3']); + }); + + await module.close(projectPath); + finishAttach?.(); + await Promise.resolve(); + await Promise.resolve(); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + }); + + it('closes the browser and rejects queued commands when active CDP times out', async () => { + vi.useFakeTimers(); + try { + const { module, view } = await openBrowser(); + view.webContents.debugger.responders.set( + 'Runtime.evaluate', + async () => await new Promise(() => undefined), + ); + view.webContents.debugger.responders.set('Performance.getMetrics', async () => ({ + second: true, + })); + + const first = module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + timeoutMs: 10, + }).catch((error: unknown) => error); + const second = module.sendCdp({ + projectPath, + method: 'Performance.getMetrics', + }).catch((error: unknown) => error); + const navigation = module.navigate({ + projectPath, + action: 'reload', + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10); + expect(await first).toMatchObject({ + code: 'CDP_TIMEOUT', + outcome: 'unknown', + }); + expect(await second).toMatchObject({ code: 'CLOSED' }); + expect(await navigation).toMatchObject({ code: 'CLOSED' }); + expect(view.webContents.debugger.commands.some( + (command) => command.method === 'Performance.getMetrics', + )).toBe(false); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + } finally { + vi.useRealTimers(); + } + }); + + it('times out a CDP command while it is queued behind a hung command', async () => { + vi.useFakeTimers(); + try { + const { module, view } = await openBrowser(); + view.webContents.debugger.responders.set( + 'Runtime.evaluate', + async () => await new Promise(() => undefined), + ); + const first = module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + timeoutMs: 100, + }).catch((error: unknown) => error); + const queued = module.sendCdp({ + projectPath, + method: 'Performance.getMetrics', + timeoutMs: 20, + }).catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(20); + await expect(first).resolves.toMatchObject({ code: 'CLOSED' }); + await expect(queued).resolves.toMatchObject({ + code: 'CDP_TIMEOUT', + outcome: 'unknown', + }); + expect(view.webContents.debugger.commands.some( + (command) => command.method === 'Performance.getMetrics', + )).toBe(false); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + } finally { + vi.useRealTimers(); + } + }); + + it('preemptively closes a timed-out command and allows a new generation to open', async () => { + vi.useFakeTimers(); + try { + const { adapter, module, view } = await openBrowser(); + let resolveLate: ((value: unknown) => void) | undefined; + view.webContents.debugger.responders.set( + 'Runtime.evaluate', + async () => await new Promise((resolvePromise) => { + resolveLate = resolvePromise; + }), + ); + const command = module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + timeoutMs: 10, + }); + const commandResult = command.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10); + expect(await commandResult).toMatchObject({ code: 'CDP_TIMEOUT' }); + + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + const reopened = module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3001', + bounds: { x: 0, y: 0, width: 800, height: 600 }, + }); + await expect(reopened).resolves.toMatchObject({ + state: 'attached', + generation: 2, + }); + expect(adapter.views).toHaveLength(2); + + resolveLate?.({ result: { value: 'late' } }); + await vi.advanceTimersByTimeAsync(0); + await expect(module.getSnapshot(projectPath)).resolves.toMatchObject({ + state: 'attached', + generation: 2, + url: 'http://localhost:3001/', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('cleans up the debugger, view, payloads, and profile on close/reset', async () => { + const { adapter, module, view } = await openBrowser(); + await module.close(projectPath); + + expect(view.webContents.debugger.attached).toBe(false); + expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); + await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); + + await module.resetProfile(projectPath); + expect(adapter.resetPartitions).toEqual([agentBrowserPartition(projectPath)]); + }); + + it('does not reopen a project partition until profile reset has finished', async () => { + const adapter = new FakeAdapter(); + let finishReset: (() => void) | undefined; + adapter.resetHandler = async () => await new Promise((resolvePromise) => { + finishReset = resolvePromise; + }); + const { module } = await openBrowser(adapter); + + const resetting = module.resetProfile(projectPath); + await vi.waitFor(() => expect(adapter.resetPartitions).toHaveLength(1)); + const reopening = module.open({ + projectId: 'clock', + projectPath, + url: 'http://localhost:3001', + bounds: { x: 0, y: 0, width: 800, height: 600 }, + }); + await Promise.resolve(); + expect(adapter.views).toHaveLength(1); + + finishReset?.(); + await expect(resetting).resolves.toMatchObject({ state: 'closed' }); + await expect(reopening).resolves.toMatchObject({ + state: 'attached', + url: 'http://localhost:3001/', + }); + expect(adapter.views).toHaveLength(2); + }); + + it('surfaces renderer crashes and rejects access from a different project', async () => { + const { module, view } = await openBrowser(); + await expect(module.getSnapshot('D:\\student\\other')).rejects.toMatchObject({ + code: 'PROJECT_MISMATCH', + }); + + view.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); + await expect(module.getSnapshot(projectPath)).resolves.toMatchObject({ + state: 'crashed', + error: { code: 'RENDERER_CRASHED' }, + }); + await expect(module.sendCdp({ + projectPath, + method: 'Runtime.evaluate', + })).rejects.toMatchObject({ code: 'RENDERER_CRASHED' }); + }); +}); + +describe('AgentBrowserEventBuffer', () => { + it('bounds retained events and reports an explicit eviction gap', () => { + const buffer = new AgentBrowserEventBuffer({ maxEvents: 2, maxBytes: 1024 }); + for (let index = 0; index < 3; index += 1) { + buffer.push({ + generation: 1, + timestamp: index, + method: `Test.event${index}`, + }); + } + + expect(buffer.read(0)).toMatchObject({ + events: [ + { sequence: 2, method: 'Test.event1' }, + { sequence: 3, method: 'Test.event2' }, + ], + nextCursor: 3, + gap: { reason: 'evicted', oldestAvailable: 2 }, + }); + }); + + it('advances independent filtered readers without consuming shared events', () => { + const buffer = new AgentBrowserEventBuffer(); + buffer.push({ generation: 1, timestamp: 1, method: 'Runtime.one' }); + buffer.push({ generation: 1, timestamp: 2, method: 'Network.one' }); + + const runtime = buffer.read(0, ['Runtime.one']); + const network = buffer.read(0, ['Network.one']); + expect(runtime.events.map((event) => event.method)).toEqual(['Runtime.one']); + expect(network.events.map((event) => event.method)).toEqual(['Network.one']); + expect(runtime.nextCursor).toBe(2); + expect(network.nextCursor).toBe(2); + }); +}); + +describe('AgentBrowserPayloadStore', () => { + it('expires payloads and keeps UTF-8 chunk boundaries valid', () => { + let now = 1_000; + const store = new AgentBrowserPayloadStore({ + ttlMs: 100, + maxBytes: 1024, + maxEntryBytes: 1024, + now: () => now, + }); + const payload = store.put(Buffer.from('你好吗'), 'text/plain'); + const tiny = store.read(payload.handle, 0, 1); + expect(tiny).toMatchObject({ + data: '你', + nextOffset: 3, + done: false, + }); + const first = store.read(payload.handle, 0, 4); + expect(first).toMatchObject({ + data: '你', + encoding: 'utf8', + nextOffset: 3, + done: false, + }); + expect(() => store.read(payload.handle, 1, 3)).toThrowError( + expect.objectContaining({ code: 'INVALID_REQUEST' }), + ); + + now = 1_101; + expect(() => store.read(payload.handle)).toThrowError( + expect.objectContaining({ code: 'PAYLOAD_NOT_FOUND' }), + ); + }); + + it('evicts old payloads to honor the memory cap and rejects oversized entries', () => { + const store = new AgentBrowserPayloadStore({ + maxBytes: 10, + maxEntryBytes: 10, + }); + const first = store.put(Buffer.from('123456'), 'text/plain'); + const second = store.put(Buffer.from('abcdef'), 'text/plain'); + + expect(() => store.read(first.handle)).toThrowError( + expect.objectContaining({ code: 'PAYLOAD_NOT_FOUND' }), + ); + expect(store.read(second.handle).data).toBe('abcdef'); + expect(() => store.put(Buffer.from('12345678901'), 'text/plain')).toThrowError( + expect.objectContaining({ code: 'PAYLOAD_TOO_LARGE' }), + ); + }); +}); + +describe('AgentBrowserCdpGuard', () => { + it('only permits IO handles issued by the current CDP session', () => { + const guard = new AgentBrowserCdpGuard(); + expect(() => guard.assertAllowed( + 'IO.read', + { handle: 'owned' }, + undefined, + new Set(), + new Set(['owned']), + )).not.toThrow(); + expect(() => guard.assertAllowed( + 'IO.read', + { handle: 'foreign' }, + undefined, + new Set(), + new Set(['owned']), + )).toThrowError(AgentBrowserFault); + }); +}); diff --git a/tests/unit/agent-browser-electron-adapter.test.ts b/tests/unit/agent-browser-electron-adapter.test.ts new file mode 100644 index 0000000..fbcb098 --- /dev/null +++ b/tests/unit/agent-browser-electron-adapter.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const electronMocks = vi.hoisted(() => { + class MiniEmitter { + private readonly listeners = new Map void>>(); + + on(event: string, listener: (...args: unknown[]) => void): this { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + return this; + } + + removeListener(event: string, listener: (...args: unknown[]) => void): this { + this.listeners.get(event)?.delete(listener); + return this; + } + + removeAllListeners(): this { + this.listeners.clear(); + return this; + } + + emit(event: string, ...args: unknown[]): boolean { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + return true; + } + } + + const nativeViews: Array<{ + options: unknown; + webContents: unknown; + setBounds: ReturnType; + setVisible: ReturnType; + }> = []; + const permissionHandler = vi.fn(); + const permissionCheckHandler = vi.fn(); + const browserSession = Object.assign(new MiniEmitter(), { + setPermissionRequestHandler: permissionHandler, + setPermissionCheckHandler: permissionCheckHandler, + clearStorageData: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined), + }); + + class MockWebContentsView { + readonly webContents = Object.assign(new MiniEmitter(), { + debugger: Object.assign(new MiniEmitter(), { + attach: vi.fn(), + detach: vi.fn(), + isAttached: vi.fn().mockReturnValue(false), + sendCommand: vi.fn().mockResolvedValue({}), + }), + navigationHistory: { + canGoBack: vi.fn().mockReturnValue(false), + canGoForward: vi.fn().mockReturnValue(false), + goBack: vi.fn(), + goForward: vi.fn(), + clear: vi.fn(), + }, + session: browserSession, + loadURL: vi.fn().mockResolvedValue(undefined), + getURL: vi.fn().mockReturnValue(''), + getTitle: vi.fn().mockReturnValue(''), + isDestroyed: vi.fn().mockReturnValue(false), + isDevToolsOpened: vi.fn().mockReturnValue(false), + reload: vi.fn(), + close: vi.fn(), + setWindowOpenHandler: vi.fn(), + }); + readonly setBounds = vi.fn(); + readonly setVisible = vi.fn(); + + constructor(readonly options: unknown) { + nativeViews.push(this); + } + } + + return { + MockWebContentsView, + nativeViews, + browserSession, + permissionHandler, + permissionCheckHandler, + fromPartition: vi.fn(() => browserSession), + }; +}); + +vi.mock('electron', () => ({ + WebContentsView: electronMocks.MockWebContentsView, + session: { fromPartition: electronMocks.fromPartition }, +})); + +import { ElectronAgentBrowserAdapter } from '@electron/agent-browser/electron-adapter'; + +describe('ElectronAgentBrowserAdapter', () => { + beforeEach(() => { + electronMocks.nativeViews.length = 0; + electronMocks.permissionHandler.mockClear(); + electronMocks.permissionCheckHandler.mockClear(); + electronMocks.browserSession.removeAllListeners(); + }); + + it('creates an isolated sandboxed WebContentsView and mounts it in Main', () => { + const addChildView = vi.fn(); + const removeChildView = vi.fn(); + const mainWindow = { + isDestroyed: vi.fn().mockReturnValue(false), + contentView: { addChildView, removeChildView }, + webContents: { getZoomFactor: vi.fn().mockReturnValue(1.25) }, + getContentBounds: vi.fn().mockReturnValue({ + x: 0, + y: 0, + width: 900, + height: 650, + }), + }; + const adapter = new ElectronAgentBrowserAdapter(mainWindow as never); + + const view = adapter.createView('persist:niancode-agent-browser:test'); + adapter.mount(view); + + expect(electronMocks.nativeViews[0].options).toEqual({ + webPreferences: { + partition: 'persist:niancode-agent-browser:test', + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + expect(electronMocks.nativeViews[0].webContents).toMatchObject({ + setWindowOpenHandler: expect.any(Function), + }); + expect(electronMocks.permissionHandler).toHaveBeenCalledOnce(); + expect(electronMocks.permissionCheckHandler).toHaveBeenCalledOnce(); + expect(addChildView).toHaveBeenCalledWith(electronMocks.nativeViews[0]); + + view.setBounds({ x: 100, y: 80, width: 800, height: 600 }); + view.setVisible(true); + view.webContents.navigationHistory.clear(); + expect(electronMocks.nativeViews[0].setBounds).toHaveBeenCalledWith({ + x: 125, + y: 100, + width: 775, + height: 550, + }); + expect(electronMocks.nativeViews[0].setVisible).toHaveBeenCalledWith(true); + expect( + ( + electronMocks.nativeViews[0].webContents as { + navigationHistory: { clear: ReturnType }; + } + ).navigationHistory.clear, + ).toHaveBeenCalledOnce(); + }); + + it('denies downloads and clears only the requested project partition', async () => { + const adapter = new ElectronAgentBrowserAdapter({ + isDestroyed: vi.fn().mockReturnValue(false), + contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, + webContents: { getZoomFactor: vi.fn().mockReturnValue(1) }, + getContentBounds: vi.fn().mockReturnValue({ + x: 0, + y: 0, + width: 1024, + height: 768, + }), + } as never); + adapter.createView('persist:niancode-agent-browser:test'); + const downloadEvent = { preventDefault: vi.fn() }; + electronMocks.browserSession.emit('will-download', downloadEvent); + expect(downloadEvent.preventDefault).toHaveBeenCalledOnce(); + + await adapter.resetPartition('persist:niancode-agent-browser:test'); + expect(electronMocks.fromPartition).toHaveBeenCalledWith( + 'persist:niancode-agent-browser:test', + ); + expect(electronMocks.browserSession.clearStorageData).toHaveBeenCalled(); + expect(electronMocks.browserSession.clearCache).toHaveBeenCalled(); + }); + + it('prevents top-level navigation and redirects to non-web protocols', () => { + const adapter = new ElectronAgentBrowserAdapter({ + isDestroyed: vi.fn().mockReturnValue(false), + contentView: { addChildView: vi.fn(), removeChildView: vi.fn() }, + webContents: { getZoomFactor: vi.fn().mockReturnValue(1) }, + getContentBounds: vi.fn().mockReturnValue({ + x: 0, + y: 0, + width: 1024, + height: 768, + }), + } as never); + adapter.createView('persist:niancode-agent-browser:test'); + const contents = electronMocks.nativeViews[0].webContents as { + emit(event: string, ...args: unknown[]): boolean; + }; + const fileNavigation = { preventDefault: vi.fn() }; + const customRedirect = { preventDefault: vi.fn() }; + const webNavigation = { preventDefault: vi.fn() }; + + contents.emit('will-navigate', fileNavigation, 'file:///C:/Windows/win.ini'); + contents.emit('will-redirect', customRedirect, 'niancode://settings'); + contents.emit('will-navigate', webNavigation, 'http://127.0.0.1:5173'); + + expect(fileNavigation.preventDefault).toHaveBeenCalledOnce(); + expect(customRedirect.preventDefault).toHaveBeenCalledOnce(); + expect(webNavigation.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/agent-browser-panel.test.tsx b/tests/unit/agent-browser-panel.test.tsx new file mode 100644 index 0000000..f44f55c --- /dev/null +++ b/tests/unit/agent-browser-panel.test.tsx @@ -0,0 +1,430 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentBrowserPanel } from '@/pages/Chat/AgentBrowserPanel'; +import { deriveAgentBrowserDiagnostics } from '@/lib/agent-browser'; +import type { + AgentBrowserCdpEvent, + AgentBrowserSnapshot, +} from '../../shared/agent-browser'; + +const hostApiFetchMock = vi.hoisted(() => vi.fn()); +const hostEventListeners = vi.hoisted( + () => new Map void>(), +); + +vi.mock('@/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +vi.mock('@/lib/host-events', () => ({ + subscribeHostEvent: ( + eventName: string, + handler: (payload: unknown) => void, + ) => { + hostEventListeners.set(eventName, handler); + return () => { + hostEventListeners.delete(eventName); + }; + }, +})); + +let resizeCallback: ResizeObserverCallback | null = null; + +class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe() {} + + unobserve() {} + + disconnect() {} +} + +function snapshot( + state: AgentBrowserSnapshot['state'] = 'closed', + overrides: Partial = {}, +): AgentBrowserSnapshot { + const active = state !== 'closed' && state !== 'closing'; + return { + browserId: active ? 'browser-1' : null, + projectId: 'project-1', + projectPath: 'D:/repo', + state, + generation: active ? 1 : 0, + url: active ? 'http://localhost:4173/' : '', + title: active ? 'Student app' : '', + visible: active, + bounds: null, + canGoBack: false, + canGoForward: false, + eventCursor: 0, + ...overrides, + }; +} + +function parseBody(init?: RequestInit): Record { + return JSON.parse(String(init?.body ?? '{}')) as Record; +} + +function installBrowserApi( + initial: AgentBrowserSnapshot, + events: AgentBrowserCdpEvent[] = [], +) { + let current = initial; + let eventsReturned = false; + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.startsWith('/api/agent-browser/state?')) { + return { success: true, browser: current }; + } + if (path === '/api/agent-browser/open') { + const body = parseBody(init); + current = snapshot('attached', { + url: String(body.url), + visible: true, + bounds: body.bounds as AgentBrowserSnapshot['bounds'], + }); + return { success: true, browser: current }; + } + if (path === '/api/agent-browser/present') { + const body = parseBody(init); + current = { + ...current, + visible: body.visible === true, + bounds: (body.bounds as AgentBrowserSnapshot['bounds']) ?? current.bounds, + }; + return { success: true, browser: current }; + } + if (path === '/api/agent-browser/navigate') { + return { success: true, browser: current }; + } + if (path === '/api/agent-browser/close') { + current = snapshot(); + return { success: true, browser: current }; + } + if (path === '/api/agent-browser/cdp/events') { + const pageEvents = eventsReturned ? [] : events; + eventsReturned = true; + return { + success: true, + page: { + events: pageEvents, + nextCursor: pageEvents.at(-1)?.sequence ?? current.eventCursor, + hasMore: false, + }, + }; + } + throw new Error(`Unexpected Agent Browser request: ${path}`); + }); +} + +describe('AgentBrowserPanel', () => { + beforeEach(() => { + hostEventListeners.clear(); + resizeCallback = null; + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: MockResizeObserver, + }); + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: MockResizeObserver, + }); + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: 2, + }); + }); + + it('starts collapsed and cannot open without an active project', () => { + render(); + + expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '打开开发浏览器' })).toBeDisabled(); + }); + + it('keeps bounded placeholders for oversized Console and Network events', () => { + const diagnostics = deriveAgentBrowserDiagnostics([ + { + sequence: 1, + generation: 1, + timestamp: 1_000, + method: 'Runtime.consoleAPICalled', + payload: { + kind: 'payload', + handle: 'console-large', + byteLength: 70_000, + contentType: 'application/json', + expiresAt: '2026-07-28T00:00:00.000Z', + }, + }, + { + sequence: 2, + generation: 1, + timestamp: 1_010, + method: 'Network.requestWillBeSent', + payload: { + kind: 'payload', + handle: 'network-large', + byteLength: 80_000, + contentType: 'application/json', + expiresAt: '2026-07-28T00:00:00.000Z', + }, + }, + ]); + + expect(diagnostics.console[0]?.text).toContain('70000 字节'); + expect(diagnostics.network[0]?.url).toContain('80000 字节'); + }); + + it('keeps child-target Network requests separate when request ids collide', () => { + const diagnostics = deriveAgentBrowserDiagnostics([ + { + sequence: 1, + generation: 1, + timestamp: 1_000, + method: 'Network.requestWillBeSent', + params: { + requestId: 'shared-id', + request: { method: 'GET', url: 'http://localhost/root' }, + }, + }, + { + sequence: 2, + generation: 1, + timestamp: 1_001, + method: 'Network.requestWillBeSent', + sessionRef: 'worker-1', + params: { + requestId: 'shared-id', + request: { method: 'POST', url: 'http://localhost/worker' }, + }, + }, + ]); + + expect(diagnostics.network).toHaveLength(2); + expect(diagnostics.network.map((entry) => entry.url)).toEqual([ + 'http://localhost/root', + 'http://localhost/worker', + ]); + }); + + it('opens a local URL with viewport bounds expressed in DIP', async () => { + installBrowserApi(snapshot()); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); + const viewport = await screen.findByTestId('agent-browser-viewport'); + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + x: 120, + y: 80, + left: 120, + top: 80, + right: 540, + bottom: 400, + width: 420, + height: 320, + toJSON: () => ({}), + }); + + fireEvent.change(screen.getByRole('textbox', { name: '网页地址' }), { + target: { value: 'localhost:4173' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + await waitFor(() => { + const call = hostApiFetchMock.mock.calls.find( + ([path]) => path === '/api/agent-browser/open', + ); + expect(call).toBeDefined(); + expect(parseBody(call?.[1] as RequestInit)).toEqual({ + project_path: 'D:/repo', + url: 'http://localhost:4173', + visible: true, + bounds: { x: 120, y: 80, width: 420, height: 320 }, + }); + }); + expect(window.devicePixelRatio).toBe(2); + }); + + it('expands when the agent opens the shared browser', async () => { + installBrowserApi(snapshot('attached')); + render(); + + expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); + act(() => { + hostEventListeners.get('agent-browser:show')?.(snapshot('attached')); + }); + + expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); + expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试'); + }); + + it('ignores a delayed show event from the previous project', () => { + installBrowserApi(snapshot()); + render(); + + act(() => { + hostEventListeners.get('agent-browser:show')?.( + snapshot('attached', { + projectId: 'project-1', + projectPath: 'D:/repo-1', + }), + ); + }); + + expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); + }); + + it('restores an already active shared browser after remounting', async () => { + installBrowserApi(snapshot('attached')); + render(); + + expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); + expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试'); + }); + + it('updates native view bounds and hides it when collapsed', async () => { + installBrowserApi(snapshot('attached')); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); + expect(await screen.findByText('AI 可调试')).toBeInTheDocument(); + const viewport = screen.getByTestId('agent-browser-viewport'); + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + x: 240, + y: 96, + left: 240, + top: 96, + right: 640, + bottom: 396, + width: 400, + height: 300, + toJSON: () => ({}), + }); + resizeCallback?.([], {} as ResizeObserver); + + await waitFor(() => { + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/agent-browser/present' + && parseBody(init as RequestInit).visible === true + && JSON.stringify(parseBody(init as RequestInit).bounds) + === JSON.stringify({ x: 240, y: 96, width: 400, height: 300 }) + ))).toBe(true); + }); + + fireEvent.click(screen.getByRole('button', { name: '收起开发浏览器' })); + + await waitFor(() => { + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/agent-browser/present' + && parseBody(init as RequestInit).visible === false + ))).toBe(true); + }); + }); + + it('hides the native view while a renderer modal is open', async () => { + installBrowserApi(snapshot('attached')); + render(); + + const viewport = await screen.findByTestId('agent-browser-viewport'); + vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ + x: 240, + y: 96, + left: 240, + top: 96, + right: 640, + bottom: 396, + width: 400, + height: 300, + toJSON: () => ({}), + }); + resizeCallback?.([], {} as ResizeObserver); + await waitFor(() => { + expect(hostApiFetchMock.mock.calls.some(([path, init]) => ( + path === '/api/agent-browser/present' + && parseBody(init as RequestInit).visible === true + ))).toBe(true); + }); + const beforeModal = hostApiFetchMock.mock.calls.length; + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('data-state', 'open'); + act(() => document.body.append(dialog)); + + await waitFor(() => { + expect(hostApiFetchMock.mock.calls.slice(beforeModal).some(([path, init]) => ( + path === '/api/agent-browser/present' + && parseBody(init as RequestInit).visible === false + ))).toBe(true); + }); + const beforeClose = hostApiFetchMock.mock.calls.length; + act(() => dialog.remove()); + + await waitFor(() => { + expect(hostApiFetchMock.mock.calls.slice(beforeClose).some(([path, init]) => ( + path === '/api/agent-browser/present' + && parseBody(init as RequestInit).visible === true + ))).toBe(true); + }); + }); + + it('derives Console and Network panels from the same CDP event page', async () => { + installBrowserApi(snapshot('attached'), [ + { + sequence: 1, + generation: 1, + timestamp: 1_000, + method: 'Runtime.consoleAPICalled', + params: { + type: 'log', + args: [{ type: 'string', value: 'ready' }], + }, + }, + { + sequence: 2, + generation: 1, + timestamp: 1_010, + method: 'Network.requestWillBeSent', + params: { + requestId: 'req-1', + type: 'Fetch', + request: { method: 'GET', url: 'http://localhost:4173/api/data' }, + }, + }, + { + sequence: 3, + generation: 1, + timestamp: 1_030, + method: 'Network.responseReceived', + params: { + requestId: 'req-1', + type: 'Fetch', + response: { + url: 'http://localhost:4173/api/data', + status: 200, + statusText: 'OK', + mimeType: 'application/json', + }, + }, + }, + { + sequence: 4, + generation: 1, + timestamp: 1_060, + method: 'Network.loadingFinished', + params: { requestId: 'req-1', encodedDataLength: 42 }, + }, + ]); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); + expect(await screen.findByText('ready')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: /Network/ })); + expect(await screen.findByText('http://localhost:4173/api/data')).toBeInTheDocument(); + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('50ms')).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/agent-browser-plugin.test.ts b/tests/unit/agent-browser-plugin.test.ts new file mode 100644 index 0000000..32b56bd --- /dev/null +++ b/tests/unit/agent-browser-plugin.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ToolContext } from '@opencode-ai/plugin/tool'; +import { NianCodeAgentBrowserPlugin } from '../../.opencode/plugins/agent-browser-tools'; + +const originalBaseUrl = process.env.NIANCODE_HOST_API_BASE_URL; +const originalToken = process.env.NIANCODE_HOST_API_TOKEN; + +function toolContext(directory: string): ToolContext { + return { + sessionID: 'session-1', + messageID: 'message-1', + agent: 'build', + directory, + worktree: directory, + abort: new AbortController().signal, + metadata: vi.fn(), + ask: vi.fn(), + }; +} + +function response(payload: Record, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + if (originalBaseUrl === undefined) delete process.env.NIANCODE_HOST_API_BASE_URL; + else process.env.NIANCODE_HOST_API_BASE_URL = originalBaseUrl; + if (originalToken === undefined) delete process.env.NIANCODE_HOST_API_TOKEN; + else process.env.NIANCODE_HOST_API_TOKEN = originalToken; +}); + +describe('Agent Browser OpenCode plugin', () => { + it('loads the bundled runtime plugin artifact', async () => { + const bundled = await import( + '../../.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js' + ); + + expect(bundled.NianCodeAgentBrowserPlugin).toBeTypeOf('function'); + }); + + it('binds lifecycle requests to context.directory without exposing model-selected ids', async () => { + process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210/'; + process.env.NIANCODE_HOST_API_TOKEN = 'host-token'; + const fetchMock = vi.fn(async () => response({ + success: true, + browser: { state: 'attached', url: 'http://localhost:5173' }, + })); + vi.stubGlobal('fetch', fetchMock); + const plugin = await NianCodeAgentBrowserPlugin(); + + await plugin.tool.browser_context.execute( + { action: 'open', url: 'http://localhost:5173' }, + toolContext('D:\\Students\\demo'), + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:43210/api/agent-browser/open'); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.headers).toMatchObject({ Authorization: 'Bearer host-token' }); + expect(JSON.parse(String(init.body))).toEqual({ + project_path: 'D:\\Students\\demo', + url: 'http://localhost:5173', + }); + expect(String(init.body)).not.toMatch(/projectId|tabId|webContentsId/); + }); + + it('returns Full CDP results and event pages without stripping nested data', async () => { + process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210'; + process.env.NIANCODE_HOST_API_TOKEN = 'host-token'; + const fullResult = { + kind: 'inline', + value: { + result: { + type: 'object', + value: { headers: { authorization: 'page-owned-value' }, nested: [1, 2, 3] }, + }, + }, + }; + const fullPage = { + events: [{ + sequence: 41, + method: 'Network.responseReceived', + params: { response: { headers: { 'x-debug': 'complete' } } }, + }], + nextCursor: 41, + hasMore: false, + }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(response({ success: true, result: fullResult })) + .mockResolvedValueOnce(response({ success: true, page: fullPage })); + vi.stubGlobal('fetch', fetchMock); + const plugin = await NianCodeAgentBrowserPlugin(); + const context = toolContext('D:\\Students\\demo'); + + const sendResult = await plugin.tool.browser_cdp.execute({ + action: 'send', + method: 'Runtime.evaluate', + params: { expression: 'window.__debugState' }, + }, context); + const eventsResult = await plugin.tool.browser_cdp.execute({ + action: 'read_events', + after: 30, + methods: ['Network.responseReceived'], + }, context); + + expect(sendResult).toMatchObject({ output: JSON.stringify(fullResult, null, 2) }); + expect(eventsResult).toMatchObject({ output: JSON.stringify(fullPage, null, 2) }); + expect(JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body))).toEqual({ + project_path: 'D:\\Students\\demo', + method: 'Runtime.evaluate', + params: { expression: 'window.__debugState' }, + }); + expect(JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body))).toEqual({ + project_path: 'D:\\Students\\demo', + after: 30, + methods: ['Network.responseReceived'], + }); + }); +}); diff --git a/tests/unit/agent-browser-routes.test.ts b/tests/unit/agent-browser-routes.test.ts new file mode 100644 index 0000000..7af30c1 --- /dev/null +++ b/tests/unit/agent-browser-routes.test.ts @@ -0,0 +1,348 @@ +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleAgentBrowserRoutes } from '@electron/api/routes/agent-browser'; +import { + getRendererCapability, + RENDERER_CAPABILITY_HEADER, + rotateRendererCapability, +} from '@electron/api/renderer-capability'; + +function createRequest( + method: string, + body?: unknown, + headers: Record = {}, +): IncomingMessage { + const req = new EventEmitter(); + Object.assign(req, { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...headers, + }, + [Symbol.asyncIterator]: async function* () { + if (body !== undefined) yield Buffer.from(JSON.stringify(body)); + }, + }); + return req as IncomingMessage; +} + +function createResponse() { + const chunks: string[] = []; + const res = { + statusCode: 0, + setHeader: vi.fn(), + end: vi.fn((chunk?: string) => { + if (chunk) chunks.push(chunk); + }), + } as unknown as ServerResponse; + return { + res, + json: () => JSON.parse(chunks.join('')) as Record, + }; +} + +function snapshot(projectPath: string) { + return { + browserId: 'browser-1', + projectId: 'project-1', + projectPath, + state: 'attached' as const, + generation: 1, + url: 'http://127.0.0.1:5173', + title: 'Example', + visible: true, + bounds: { x: 0, y: 0, width: 640, height: 480 }, + canGoBack: false, + canGoForward: false, + eventCursor: 0, + }; +} + +function context(projectPath: string, browser: Record) { + return { + opencodeProjectStore: { + getActiveProject: vi.fn().mockResolvedValue({ + id: 'project-1', + path: projectPath, + name: 'project', + }), + }, + eventBus: { emit: vi.fn() }, + mainWindow: null, + agentBrowser: browser, + } as never; +} + +describe('Agent Browser Host API routes', () => { + const temporaryDirectories: string[] = []; + + beforeEach(() => { + rotateRendererCapability(); + }); + + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); + }); + + async function temporaryProject(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; + } + + it('opens the browser for the active project and emits a visible hint', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-route-'); + const open = vi.fn().mockResolvedValue(snapshot(projectPath)); + const ctx = context(projectPath, { open }); + const response = createResponse(); + + const handled = await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + url: 'http://127.0.0.1:5173', + bounds: { x: 10, y: 20, width: 640, height: 480 }, + }, { + [RENDERER_CAPABILITY_HEADER]: getRendererCapability(), + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + ctx, + ); + + expect(handled).toBe(true); + expect(response.res.statusCode).toBe(200); + expect(response.json()).toMatchObject({ success: true, browser: { state: 'attached' } }); + expect(open).toHaveBeenCalledWith(expect.objectContaining({ + projectId: 'project-1', + projectPath, + url: 'http://127.0.0.1:5173', + bounds: { x: 10, y: 20, width: 640, height: 480 }, + })); + expect((ctx as never as { eventBus: { emit: ReturnType } }).eventBus.emit) + .toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' })); + }); + + it('rejects viewport bounds from an agent-only Host API request', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-untrusted-bounds-'); + const open = vi.fn(); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + url: 'http://127.0.0.1:5173', + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + context(projectPath, { open }), + ); + + expect(response.res.statusCode).toBe(403); + expect(response.json()).toMatchObject({ + success: false, + code: 'TARGET_DENIED', + }); + expect(open).not.toHaveBeenCalled(); + }); + + it('forces an agent-only open request to remain hidden until Renderer presents it', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-agent-open-'); + const open = vi.fn().mockResolvedValue(snapshot(projectPath)); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + url: 'http://127.0.0.1:5173', + visible: true, + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + context(projectPath, { open }), + ); + + expect(response.res.statusCode).toBe(200); + expect(open).toHaveBeenCalledWith(expect.objectContaining({ + bounds: undefined, + visible: false, + })); + }); + + it('forwards full CDP commands without stripping params', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-cdp-'); + const sendCdp = vi.fn().mockResolvedValue({ + kind: 'inline', + value: { result: { value: 'secret page value' } }, + }); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + method: 'Runtime.evaluate', + params: { + expression: 'window.localStorage.getItem("token")', + returnByValue: true, + }, + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/cdp/send'), + context(projectPath, { sendCdp }), + ); + + expect(response.res.statusCode).toBe(200); + expect(sendCdp).toHaveBeenCalledWith(expect.objectContaining({ + projectPath, + method: 'Runtime.evaluate', + params: { + expression: 'window.localStorage.getItem("token")', + returnByValue: true, + }, + })); + expect(response.json()).toMatchObject({ + success: true, + result: { kind: 'inline', value: { result: { value: 'secret page value' } } }, + }); + }); + + it('rejects a project path that is not the active project', async () => { + const activePath = await temporaryProject('niancode-agent-browser-active-'); + const otherPath = await temporaryProject('niancode-agent-browser-other-'); + const open = vi.fn(); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: otherPath, + url: 'http://127.0.0.1:5173', + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + context(activePath, { open }), + ); + + expect(response.res.statusCode).toBe(403); + expect(response.json()).toMatchObject({ + success: false, + code: 'PROJECT_MISMATCH', + }); + expect(open).not.toHaveBeenCalled(); + }); + + it('requires the calling agent to identify its project path', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-required-path-'); + const open = vi.fn(); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + url: 'http://127.0.0.1:5173', + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + context(projectPath, { open }), + ); + + expect(response.res.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + code: 'INVALID_REQUEST', + }); + expect(open).not.toHaveBeenCalled(); + }); + + it('closes a stale browser when the active project changes during open', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-race-old-'); + const nextProjectPath = await temporaryProject('niancode-agent-browser-race-new-'); + const open = vi.fn().mockResolvedValue(snapshot(projectPath)); + const close = vi.fn().mockResolvedValue(snapshot(projectPath)); + const getActiveProject = vi.fn() + .mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' }) + .mockResolvedValueOnce({ id: 'project-2', path: nextProjectPath, name: 'new' }); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + url: 'http://127.0.0.1:5173', + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/open'), + { + opencodeProjectStore: { getActiveProject }, + eventBus: { emit: vi.fn() }, + mainWindow: null, + agentBrowser: { open, close }, + } as never, + ); + + expect(response.res.statusCode).toBe(403); + expect(response.json()).toMatchObject({ + success: false, + code: 'PROJECT_MISMATCH', + }); + expect(close).toHaveBeenCalledWith(projectPath); + }); + + it('rejects stale results when the active project keeps its id but changes path', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-path-old-'); + const nextProjectPath = await temporaryProject('niancode-agent-browser-path-new-'); + const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: { result: 42 } }); + const close = vi.fn().mockResolvedValue(snapshot(projectPath)); + const getActiveProject = vi.fn() + .mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' }) + .mockResolvedValueOnce({ id: 'project-1', path: nextProjectPath, name: 'moved' }); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + method: 'Runtime.evaluate', + params: { expression: '42' }, + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/cdp/send'), + { + opencodeProjectStore: { getActiveProject }, + eventBus: { emit: vi.fn() }, + mainWindow: null, + agentBrowser: { sendCdp, close }, + } as never, + ); + + expect(response.res.statusCode).toBe(403); + expect(response.json()).toMatchObject({ + success: false, + code: 'PROJECT_MISMATCH', + }); + expect(close).toHaveBeenCalledWith(projectPath); + }); + + it('does not expose an arbitrary target id in the route contract', async () => { + const projectPath = await temporaryProject('niancode-agent-browser-target-'); + const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: {} }); + const response = createResponse(); + + await handleAgentBrowserRoutes( + createRequest('POST', { + project_path: projectPath, + method: 'DOM.getDocument', + targetId: 'another-electron-tab', + }), + response.res, + new URL('http://127.0.0.1/api/agent-browser/cdp/send'), + context(projectPath, { sendCdp }), + ); + + expect(sendCdp).toHaveBeenCalledWith(expect.not.objectContaining({ + targetId: expect.anything(), + })); + }); +}); diff --git a/tests/unit/host-api-proxy.test.ts b/tests/unit/host-api-proxy.test.ts index e97e918..af063b2 100644 --- a/tests/unit/host-api-proxy.test.ts +++ b/tests/unit/host-api-proxy.test.ts @@ -14,6 +14,11 @@ vi.mock('../../electron/api/server', () => ({ getHostApiToken: () => 'host-token', })); +vi.mock('../../electron/api/renderer-capability', () => ({ + getRendererCapability: () => 'renderer-token', + RENDERER_CAPABILITY_HEADER: 'x-niancode-renderer-capability', +})); + describe('Host API IPC proxy', () => { beforeEach(() => { vi.resetModules(); @@ -47,6 +52,7 @@ describe('Host API IPC proxy', () => { headers: expect.objectContaining({ Authorization: 'Bearer host-token', 'Content-Type': 'application/json', + 'x-niancode-renderer-capability': 'renderer-token', }), }), ); diff --git a/tests/unit/host-events.test.ts b/tests/unit/host-events.test.ts index 929e4f2..d9ad504 100644 --- a/tests/unit/host-events.test.ts +++ b/tests/unit/host-events.test.ts @@ -44,6 +44,23 @@ describe('host-events', () => { expect(cleanupSpy).toHaveBeenCalledTimes(1); }); + it.each(['agent-browser:show', 'agent-browser:state'])( + 'maps %s to the preload IPC bridge', + async (eventName) => { + const onMock = vi.mocked(window.electron.ipcRenderer.on); + const cleanupSpy = vi.fn(); + onMock.mockReturnValue(cleanupSpy); + + const { subscribeHostEvent } = await import('@/lib/host-events'); + const unsubscribe = subscribeHostEvent(eventName, vi.fn()); + + expect(onMock).toHaveBeenCalledWith(eventName, expect.any(Function)); + expect(createHostEventSourceMock).not.toHaveBeenCalled(); + unsubscribe(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }, + ); + it('does not use SSE fallback by default for unknown events', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { subscribeHostEvent } = await import('@/lib/host-events'); diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 14250f7..9ec0731 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -954,6 +954,7 @@ describe('OpencodeChatPanel', () => { expect(agentSidebar.querySelector('.overflow-y-auto')).toBeInTheDocument(); expect(chatLayout).toHaveClass('flex-row'); expect(chatLayout.firstElementChild).toBe(agentSidebar); + expect(screen.getByRole('button', { name: '打开开发浏览器' })).toBeInTheDocument(); expect(screen.getByTestId('opencode-chat-panel')).not.toHaveClass('pt-4', 'sm:pt-5'); expect(screen.getAllByTestId(/^project-agent-chat-/)).toHaveLength(5); expect(screen.getByTestId('project-agent-chat-game-promotion')).toHaveTextContent('运营宣传角色'); diff --git a/tests/unit/opencode-manager.test.ts b/tests/unit/opencode-manager.test.ts index e1ce290..5a792ce 100644 --- a/tests/unit/opencode-manager.test.ts +++ b/tests/unit/opencode-manager.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { OpencodeManager } from '@electron/opencode/manager'; import { ensureBundledSuperpowersPlugin, + resolveBundledAgentBrowserPluginPath, resolveBundledSuperpowersDir, } from '@electron/opencode/superpowers'; import { logger } from '@electron/utils/logger'; @@ -82,6 +83,23 @@ describe('OpencodeManager', () => { })).toBe(join(resourcesPath, 'resources', 'skills', 'superpowers')); }); + it('resolves the bundled Agent Browser plugin from the course skills bundle', () => { + const resourcesPath = 'C:\\Program Files\\Makelore\\resources'; + + expect(resolveBundledAgentBrowserPluginPath({ + isPackaged: true, + resourcesPath, + appPath: 'C:\\Program Files\\Makelore\\resources\\app.asar', + })).toBe(join( + resourcesPath, + 'course-skills', + 'agent-browser', + '.opencode', + 'plugins', + 'niancode-agent-browser.js', + )); + }); + it('spawns the bundled opencode server and becomes running after listening output', async () => { const { children, calls, spawn } = createSpawnHarness(); const manager = new OpencodeManager({ @@ -858,6 +876,50 @@ describe('OpencodeManager', () => { } }); + it('installs the bundled Agent Browser tool plugin before spawning', async () => { + const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-')); + const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-agent-browser-plugin-source-')); + const sourcePluginPath = join(sourceDir, 'niancode-agent-browser.js'); + try { + writeFileSync( + sourcePluginPath, + 'export const NianCodeAgentBrowserPlugin = async () => ({});\n', + ); + + const { children, spawn } = createSpawnHarness(); + const manager = new OpencodeManager({ + port: 4337, + binPath: '/opt/opencode', + userDataDir, + bundledAgentBrowserPluginPath: sourcePluginPath, + runtimeConfigProvider: () => ({ config: {}, env: {} }), + spawn, + }); + + const startPromise = manager.start(); + children[0].stdout.emit( + 'data', + Buffer.from('opencode server listening on http://127.0.0.1:4337\n'), + ); + await startPromise; + + const installedPluginPath = join( + userDataDir, + 'opencode', + 'niancode-config', + 'plugins', + 'niancode-agent-browser.js', + ); + expect(existsSync(installedPluginPath)).toBe(true); + expect(readFileSync(installedPluginPath, 'utf8')).toContain( + 'NianCodeAgentBrowserPlugin', + ); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + rmSync(sourceDir, { recursive: true, force: true }); + } + }); + it('removes retired bundled skills from the managed config before spawning', async () => { const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-')); const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-')); diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index 0725aac..b68fb71 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -677,6 +677,58 @@ describe('opencode host api routes', () => { } }); + it('closes the shared browser before and after switching projects', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'niancode-switch-active-')); + try { + await writeValidProjectConfig(projectPath); + const response = createResponse(); + const operations: string[] = []; + const currentProject = { + id: 'prj_current', + path: join(projectPath, 'current'), + name: 'current', + }; + const nextProject = { + id: 'prj_next', + path: projectPath, + name: 'next', + }; + const closeBrowser = vi.fn(async () => { + operations.push('close-browser'); + return {}; + }); + let activeProject = currentProject; + + const handled = await handleOpencodeRoutes( + createRequest('POST', { projectId: nextProject.id }), + response.res, + new URL('http://127.0.0.1/api/opencode/projects/active'), + { + opencodeProjectStore: { + setActiveProject: vi.fn(async () => { + operations.push('set-active'); + activeProject = nextProject; + return nextProject; + }), + listProjects: vi.fn(async () => [currentProject, nextProject]), + getActiveProject: vi.fn(async () => activeProject), + }, + agentBrowser: { + close: closeBrowser, + }, + } as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(200); + expect(closeBrowser).toHaveBeenCalledTimes(2); + expect(closeBrowser).toHaveBeenCalledWith(currentProject.path); + expect(operations).toEqual(['close-browser', 'set-active', 'close-browser']); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + it('rejects active project selection when the project configuration is missing', async () => { const projectPath = await mkdtemp(join(tmpdir(), 'niancode-set-active-missing-')); try { @@ -790,6 +842,51 @@ describe('opencode host api routes', () => { } }); + it('closes the shared browser before and after removing the active project', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'niancode-remove-browser-')); + try { + const response = createResponse(); + const activeProject = { + id: 'prj_removed', + path: projectPath, + name: 'removed', + }; + const operations: string[] = []; + const closeBrowser = vi.fn(async () => { + operations.push('close-browser'); + return {}; + }); + let storedActiveProject: typeof activeProject | null = activeProject; + + const handled = await handleOpencodeRoutes( + createRequest('POST', { projectId: activeProject.id }), + response.res, + new URL('http://127.0.0.1/api/opencode/projects/remove'), + { + opencodeProjectStore: { + removeProject: vi.fn(async () => { + operations.push('remove-project'); + storedActiveProject = null; + }), + listProjects: vi.fn(async () => []), + getActiveProject: vi.fn(async () => storedActiveProject), + }, + agentBrowser: { + close: closeBrowser, + }, + } as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(200); + expect(closeBrowser).toHaveBeenCalledTimes(2); + expect(closeBrowser).toHaveBeenCalledWith(activeProject.path); + expect(operations).toEqual(['close-browser', 'remove-project', 'close-browser']); + } finally { + await rm(projectPath, { recursive: true, force: true }); + } + }); + it('reports a missing root works-publish.json for a known project', async () => { const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-missing-')); try {