feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
223
.opencode/plugins/agent-browser-tools.ts
Normal file
223
.opencode/plugins/agent-browser-tools.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { tool } from '@opencode-ai/plugin/tool';
|
||||
|
||||
type HostPayload = Record<string, unknown>;
|
||||
|
||||
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<string, string> = {
|
||||
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<HostPayload> {
|
||||
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);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user