Files
makelore/.opencode/plugins/agent-browser-tools.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

224 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
},
}),
},
});