feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
(把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
This commit is contained in:
1 parent
ea150ceea9
commit
f1fbc249fd
21 files changed
+3579
No files matched your search
@@ -0,0 +1,219 @@
|
||||
// Package browser 是 `browser` 工具(design/28 B2 的**自研薄层**)。
|
||||
//
|
||||
// 它只补本项目独有的那一层:把「本机真 Chromium 有头」的**导航 / 截图 / 指纹自检**
|
||||
// 暴露给模型,并把截图按 0600 落盘成一个 **vision/image-crop 能直接读的本地路径** ——
|
||||
// 那才是「截图进上下文」的闭环入口(design/28 §1 M4、§5)。
|
||||
//
|
||||
// 为什么只有三个动作(AGENTS §9.5):`click` / `type` / `snapshot` / `extract` /
|
||||
// `wait` 这一整套成熟工具面**优先复用 MCP**(Playwright MCP / Puppeteer MCP 官方
|
||||
// server,`design/09 §1`:MCP server = 外部工具提供者,工具自动注册进 registry)。
|
||||
// 本项目**不重造**一遍等价物;本包只补 MCP 给不了的两件事 ——
|
||||
// ① 宿主真 Chromium 的默认有头 + 持久 profile 启动(过风控三件套);
|
||||
// ② 截图落盘 → 经既有 image/vision 管线进上下文的闭环。
|
||||
//
|
||||
// 权限(design/28 §4 第 2 条):本工具**默认 owner-only** —— 与其它能力一样,
|
||||
// 注册时带的是零值 ToolPolicy(registry 的口径:两布尔皆 false = 只有主人环境可调),
|
||||
// 群聊/陌生人不因它放宽。
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 工具名与动作名(模型看到的那一面)。名字一旦漂移,`cmd/cyrene` 的接线测试会红。
|
||||
const (
|
||||
// ToolName 是工具在能力注册表里的名字。
|
||||
ToolName = "browser"
|
||||
|
||||
// actionOpen / actionScreenshot / actionInfo 是三个动作。
|
||||
actionOpen = "open"
|
||||
actionScreenshot = "screenshot"
|
||||
actionInfo = "info"
|
||||
)
|
||||
|
||||
// logSource 是本包的日志来源(AGENTS §10:禁止无来源裸日志)。
|
||||
const logSource = "plugins/tools/browser"
|
||||
|
||||
// Compile-time assertion that Tool implements sdk.Tool.
|
||||
var _ sdk.Tool = (*Tool)(nil)
|
||||
|
||||
// Tool 是 `browser` 工具。它只依赖驱动层的 Manager(进程内依赖注入,不起第二套
|
||||
// 浏览器栈),因此本包可单测(假 Manager 接口)。
|
||||
type Tool struct {
|
||||
mgr Driver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体
|
||||
// 类型,是为了让工具层能在**不装浏览器**的机器上被单测覆盖。
|
||||
type Driver interface {
|
||||
Available() bool
|
||||
Source() string
|
||||
Open(ctx context.Context, rawURL string) (browsercap.PageResult, error)
|
||||
Screenshot(ctx context.Context) (browsercap.Shot, error)
|
||||
Fingerprint(ctx context.Context) (browsercap.Fingerprint, error)
|
||||
}
|
||||
|
||||
// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」
|
||||
// (组合根的正确做法是压根不注册它,见 wire_browser.go)。
|
||||
func New(mgr Driver, opts ...Option) *Tool {
|
||||
t := &Tool{mgr: mgr, log: discardLog()}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(t)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:New(nil) 或 WithLogger(nil)
|
||||
// 都不该让 t.log 变成 nil(AGENTS §10 禁止无来源裸日志,也不该在 nil 上 panic)。
|
||||
func discardLog() *slog.Logger {
|
||||
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource)
|
||||
}
|
||||
|
||||
// Option 配置 Tool。
|
||||
type Option func(*Tool)
|
||||
|
||||
// WithLogger 注入基日志(本包会补 `plugins/tools/browser` 来源标签)。
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
if l == nil {
|
||||
return func(t *Tool) { t.log = discardLog() }
|
||||
}
|
||||
return func(t *Tool) { t.log = logger.WithSource(l, logSource) }
|
||||
}
|
||||
|
||||
// Name 实现 sdk.Tool。
|
||||
func (t *Tool) Name() string { return ToolName }
|
||||
|
||||
// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。
|
||||
//
|
||||
// ⚠️ **当前没有生产调用者**(如实登记):组合根的关闭链在 `a.close`(`wire_services.go`)
|
||||
// 与 `app` 结构体(`wire.go`)里,两者都不属本切片的文件面 ⇒ 加不上「关掉它」这一行。
|
||||
// 本方法先把**挂点**摆出来,供主会话在关闭链上接一行;在被接上之前,残留的浏览器由
|
||||
// 驱动层的两道机制兜住:① 空闲回收时对会发 `Browser.close` 优雅关停;② 下一次会话
|
||||
// **认领**同一个 profile 上还活着的实例(不再起第二个、也不会被 profile 锁卡死)。
|
||||
func (t *Tool) Close() error {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
if c, ok := t.mgr.(io.Closer); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Description 实现 sdk.Tool。**能力边界写在这里**:模型只读这段文字,所以「哪些
|
||||
// 动作没做、 richer 动作去 MCP」必须说清楚,不能让模型以为它有 click/type。
|
||||
func (t *Tool) Description() string {
|
||||
return "Drive a real headful Chromium on this host and bring a page into the conversation. " +
|
||||
"Actions: " +
|
||||
"'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " +
|
||||
"'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " +
|
||||
"pass that path to the vision or image-crop tool to actually read the page, which is how a " +
|
||||
"rendered chart/QR/login page becomes visible to a text-only model; " +
|
||||
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
|
||||
"for verifying that a headful session really is headful. " +
|
||||
"Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " +
|
||||
"configure a Playwright or Puppeteer MCP server and use its tools for that."
|
||||
}
|
||||
|
||||
// Parameters 实现 sdk.Tool。
|
||||
func (t *Tool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{actionOpen, actionScreenshot, actionInfo},
|
||||
"description": "Which browser action to run.",
|
||||
},
|
||||
"url": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Absolute http(s) URL. Required for action=open, ignored otherwise.",
|
||||
},
|
||||
},
|
||||
"required": []any{"action"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute 实现 sdk.Tool。
|
||||
func (t *Tool) Execute(ctx context.Context, args any) (any, error) {
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("browser: args must be an object")
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(argString(m, "action")))
|
||||
if t.mgr == nil || !t.mgr.Available() {
|
||||
return nil, fmt.Errorf("browser: 本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)")
|
||||
}
|
||||
switch action {
|
||||
case actionOpen:
|
||||
return t.open(ctx, m)
|
||||
case actionScreenshot:
|
||||
return t.screenshot(ctx)
|
||||
case actionInfo:
|
||||
return t.info(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("browser: 未知 action %q(可用:%s / %s / %s)", action,
|
||||
actionOpen, actionScreenshot, actionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// open 处理 action=open。
|
||||
func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) {
|
||||
rawURL := strings.TrimSpace(argString(args, "url"))
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("browser: action=open 需要 url")
|
||||
}
|
||||
res, err := t.mgr.Open(ctx, rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步
|
||||
// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。
|
||||
func (t *Tool) screenshot(ctx context.Context) (any, error) {
|
||||
shot, err := t.mgr.Screenshot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"path": shot.Path,
|
||||
"bytes": shot.Bytes,
|
||||
"sha256": shot.SHA256,
|
||||
"format": shot.Format,
|
||||
"width": shot.Width,
|
||||
"height": shot.Height,
|
||||
"dimensionsKnown": shot.DimensionsKnown,
|
||||
"degraded": shot.Degraded,
|
||||
"hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// info 处理 action=info。
|
||||
func (t *Tool) info(ctx context.Context) (any, error) {
|
||||
fp, err := t.mgr.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// argString 取一个字符串参数(缺失/类型不符一律当空串,与 io 包的同名口径一致)。
|
||||
func argString(m map[string]any, key string) string {
|
||||
if raw, ok := m[key].(string); ok {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// fakeDriver 是 Tool 依赖的最小驱动面(*browsercap.Manager 的真实现由驱动包的测试覆盖;
|
||||
// 这里只验证**工具层**的接线与能力边界)。
|
||||
type fakeDriver struct {
|
||||
available bool
|
||||
openedURL string
|
||||
openErr error
|
||||
shot browsercap.Shot
|
||||
fp browsercap.Fingerprint
|
||||
}
|
||||
|
||||
func (f *fakeDriver) Available() bool { return f.available }
|
||||
func (f *fakeDriver) Source() string { return "fake" }
|
||||
func (f *fakeDriver) Open(_ context.Context, raw string) (browsercap.PageResult, error) {
|
||||
f.openedURL = raw
|
||||
if f.openErr != nil {
|
||||
return browsercap.PageResult{}, f.openErr
|
||||
}
|
||||
return browsercap.PageResult{URL: raw, Title: "t", ReadyState: "complete", Source: "fake"}, nil
|
||||
}
|
||||
func (f *fakeDriver) Screenshot(context.Context) (browsercap.Shot, error) { return f.shot, nil }
|
||||
func (f *fakeDriver) Fingerprint(context.Context) (browsercap.Fingerprint, error) {
|
||||
return f.fp, nil
|
||||
}
|
||||
|
||||
// TestTool_ImplementsSDKAndName 钉住工具名(注册表条目名与热重载/接线测试都依赖它)。
|
||||
func TestTool_ImplementsSDKAndName(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ sdk.Tool = New(&fakeDriver{})
|
||||
if ToolName != "browser" {
|
||||
t.Fatalf("ToolName = %q, want browser", ToolName)
|
||||
}
|
||||
if got := New(&fakeDriver{}).Name(); got != ToolName {
|
||||
t.Fatalf("Name() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_DescriptionDeclaresTheMissingActions 钉住**能力边界写进描述**:
|
||||
// 模型只读这段文字,所以必须明说 click/type 不在这里实现、要走 MCP
|
||||
// (AGENTS §9.5 的「不重造成熟工具面」要落成模型看得见的事实)。
|
||||
func TestTool_DescriptionDeclaresTheMissingActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
desc := New(&fakeDriver{}).Description()
|
||||
for _, want := range []string{"MCP", "click", "vision"} {
|
||||
if !strings.Contains(desc, want) {
|
||||
t.Errorf("描述里缺少 %q:%s", want, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_ParametersExposeExactlyThreeActions 钉住动作面就是三个(多一个都要在
|
||||
// 这里显式改写,避免「悄悄长出一整套自研工具面」)。
|
||||
func TestTool_ParametersExposeExactlyThreeActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
params, err := New(&fakeDriver{}).Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
props, _ := params["properties"].(map[string]any)
|
||||
action, _ := props["action"].(map[string]any)
|
||||
enum, _ := action["enum"].([]any)
|
||||
if len(enum) != 3 {
|
||||
t.Fatalf("动作数 = %d, want 3: %v", len(enum), enum)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range enum {
|
||||
s, _ := e.(string)
|
||||
got[s] = true
|
||||
}
|
||||
for _, want := range []string{actionOpen, actionScreenshot, actionInfo} {
|
||||
if !got[want] {
|
||||
t.Errorf("动作枚举缺 %q", want)
|
||||
}
|
||||
}
|
||||
required, _ := params["required"].([]any)
|
||||
if len(required) != 1 || required[0] != "action" {
|
||||
t.Errorf("required = %v, want [action]", required)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_UnavailableReportsHonestly 钉住「不可用不假装可用」:驱动不可用(或没注入)
|
||||
// 时每个动作都如实报错,不返回半真半假的结果。
|
||||
func TestTool_UnavailableReportsHonestly(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tool := range []*Tool{New(nil), New(&fakeDriver{available: false})} {
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo}); err == nil {
|
||||
t.Fatal("不可用时应当报错")
|
||||
} else if !strings.Contains(err.Error(), "没有可用的浏览器来源") {
|
||||
t.Errorf("错误没有说清原因: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_OpenRequiresURL 钉住 action=open 缺 url 时**不发**导航请求(而不是拿空串去导航)。
|
||||
func TestTool_OpenRequiresURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := &fakeDriver{available: true}
|
||||
tool := New(d)
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen}); err == nil {
|
||||
t.Fatal("缺 url 应当报错")
|
||||
}
|
||||
if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": " "}); err == nil {
|
||||
t.Fatal("空白 url 应当报错")
|
||||
}
|
||||
if d.openedURL != "" {
|
||||
t.Fatalf("不该发起导航,实得 %q", d.openedURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_UnknownActionNamesTheAlternatives 钉住未知动作的错误里带可用清单
|
||||
// (模型据此自我纠正,而不是盲试)。
|
||||
func TestTool_UnknownActionNamesTheAlternatives(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := New(&fakeDriver{available: true}).Execute(context.Background(), map[string]any{"action": "click"})
|
||||
if err == nil {
|
||||
t.Fatal("未知动作应当报错")
|
||||
}
|
||||
for _, want := range []string{"open", "screenshot", "info"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里应列出可用动作 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_ScreenshotReturnsClosedLoopEntry 钉住截图动作的返回值**就是闭环入口**:
|
||||
// 路径 + 指纹 + 提示「交给 vision」,而不是把图塞进上下文。
|
||||
func TestTool_ScreenshotReturnsClosedLoopEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := &fakeDriver{available: true, shot: browsercap.Shot{
|
||||
Path: "/data/browser/shots/shot-1.png", Bytes: 4242, SHA256: "abc",
|
||||
Format: "png", Width: 1264, Height: 649, DimensionsKnown: true,
|
||||
}}
|
||||
got, err := New(d).Execute(context.Background(), map[string]any{"action": actionScreenshot})
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T", got)
|
||||
}
|
||||
if m["path"] != d.shot.Path || m["sha256"] != "abc" || m["bytes"] != 4242 {
|
||||
t.Errorf("返回值丢了落盘事实: %#v", m)
|
||||
}
|
||||
hint, _ := m["hint"].(string)
|
||||
if !strings.Contains(hint, "vision") {
|
||||
t.Errorf("提示里应指向 vision 工具: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint 钉住两个透传动作不打折扣。
|
||||
func TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := &fakeDriver{available: true, fp: browsercap.Fingerprint{Headless: false, Webdriver: false, Timezone: "Asia/Shanghai"}}
|
||||
tool := New(d)
|
||||
res, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": "https://example.com/"})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if d.openedURL != "https://example.com/" {
|
||||
t.Errorf("驱动收到的 URL = %q", d.openedURL)
|
||||
}
|
||||
if pr, ok := res.(browsercap.PageResult); !ok || pr.Title != "t" {
|
||||
t.Errorf("open 结果 = %#v", res)
|
||||
}
|
||||
fp, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo})
|
||||
if err != nil {
|
||||
t.Fatalf("info: %v", err)
|
||||
}
|
||||
if got, ok := fp.(browsercap.Fingerprint); !ok || got.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("info 结果 = %#v", fp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTool_OpenSurfacesDriverError 钉住驱动错误原样上抛(不吞、不改写成「成功」)。
|
||||
func TestTool_OpenSurfacesDriverError(t *testing.T) {
|
||||
t.Parallel()
|
||||
sentinel := errors.New("browser: 导航失败: net::ERR_NAME_NOT_RESOLVED")
|
||||
_, err := New(&fakeDriver{available: true, openErr: sentinel}).Execute(
|
||||
context.Background(), map[string]any{"action": actionOpen, "url": "https://nope.example/"})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("错误应可 errors.Is 回到驱动的那一个,实得: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user