feat: 增加共享 Agent Browser 调试能力

需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
2026-07-31 14:53:36 +08:00
parent 80e8386fa6
commit e97a7ce9df
39 changed files with 6734 additions and 9 deletions

View File

@@ -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
};

View File

@@ -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 和页面内容可能包含敏感信息,只在完成当前调试所必需时读取和展示。