Files
AskaEth 378faf212e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00

312 lines
13 KiB
Go
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.
// 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
// outbox 是「本回合待送出的截图」附件箱(B3 闭环的浏览器侧,见 attach.go)。
// 它一定非 nil(New 里默认建一个):nil 箱子会让「截图静默不进上下文」变成
// 一个只能靠读代码才看得出的行为,而它恰恰是本片要修掉的缺口。
outbox *ShotOutbox
}
// 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(), outbox: NewShotOutbox()}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
if t.outbox == nil {
// WithShotOutbox(nil) 是「显式关掉附件」的写法,但工具面必须能解释自己:
// 出一个可用的箱子、由附件状态字段如实报 disabled 由**不带箱**的路径给出,
// 因此这里只兜住 nil(构造期 invariants 一处收紧,动作里就不必到处判空)。
t.outbox = NewShotOutbox()
}
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) }
}
// WithShotOutbox 换用给定的截图附件箱(B3 闭环的浏览器侧,见 attach.go)。
//
// 传入 nil 当作「不换」:箱子是**必有**的(循环的装配钩子按工具名取回它,见
// `cmd/cyrene/wire_browser_context.go`),把它做成可选只会让「截图到底进不进上下文」
// 取决于装配顺序,而不是取决于设计。
func WithShotOutbox(o *ShotOutbox) Option {
if o == nil {
return func(*Tool) {}
}
return func(t *Tool) { t.outbox = o }
}
// ShotOutbox 返回本工具的附件箱。循环的装配钩子据此取走本回合待送出的截图
// (取走即消费,见 ShotOutbox.Drain)。永不为 nil。
func (t *Tool) ShotOutbox() *ShotOutbox {
if t == nil {
return nil
}
return t.outbox
}
// State 返回面板要的**只读事实快照**(design/28 §8 B4;形状见
// `internal/capability/browser/state.go`)。它**不启动浏览器、不发请求**:
// 面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界。
//
// 驱动不支持这一面(假驱动 / 未来的别的实现)时返回零值快照 —— 面板据此如实显示
// 「未知」,而不是编一组数字。
func (t *Tool) State() browsercap.StateSnapshot {
if t == nil || t.mgr == nil {
return browsercap.StateSnapshot{}
}
src, ok := t.mgr.(interface {
State() browsercap.StateSnapshot
})
if !ok {
return browsercap.StateSnapshot{}
}
return src.State()
}
// Manager 返回驱动层的浏览器管理器(`*browsercap.Manager`;驱动不是它时返回 nil)。
//
// 它存在只为一件事:design/28 §1 M5 / §8 B5 的 web-fetch 降级档 —— 组合根要拿**当前**
// 这把工具的驱动去渲染取正文(`Manager.RenderText`),所以这里给出驱动句柄,而不是让
// 工具面再多一个动作(模型不需要 `extract` 这个动作名,那件事由 web-fetch 自动做)。
//
// 返回具体类型而不是 Driver:取正文是驱动层的能力、不在工具的三个动作里,也不该加进
// Driver 接口(那会逼每个假驱动实现一个它们不需要的方法)。单测夹具的假驱动不是
// `*browsercap.Manager` ⇒ 返回 nil,调用方据此如实报「没有可用来源」。
func (t *Tool) Manager() *browsercap.Manager {
if t == nil {
return nil
}
mgr, _ := t.mgr.(*browsercap.Manager)
return mgr
}
// Name 实现 sdk.Tool。
func (t *Tool) Name() string { return ToolName }
// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。
//
// **生产调用者**(切片 X3 接上):组合根的关闭链 `a.close`(`wire_services.go`)按工具名
// 从能力面取回本工具并关闭 —— 它持有进程外资源(我们起的真 Chromium 子进程、CDP 连接、
// profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程),因此必须显式关。
//
// 驱动层另有两道兜底,覆盖「Close 没被调用 / 关闭失败」的情形:① 空闲回收时会发
// `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 attaches it to " +
"THIS turn automatically — a sighted main model receives the image itself, a text-only " +
"model receives a vision/OCR description — so you do not have to re-read it yourself; " +
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
"for verifying that a headful session really is headful. " +
"For a zoomed region of a large page use the image-crop tool on the returned path. " +
"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:截图落盘(0600)后**把它挂进本回合的附件箱**
// (B3 闭环:循环的下一次模型调用会把它作为 image 附件送出,见 attach.go),并返回
// 落盘路径与统计。
//
// 为什么要「挂进箱子」而不是像 V5 那样只回路径:只回路径 = 把闭环交给模型自觉
// (它得记得再调一次 vision),那不是闭环。现在两条路都自动:主模型能收图 ⇒ 它看到
// 这张图;不能 ⇒ vision/OCR 工具的文字结果进上下文(design/28 §7 判据 2)。
func (t *Tool) screenshot(ctx context.Context) (any, error) {
shot, err := t.mgr.Screenshot(ctx)
if err != nil {
return nil, err
}
status := t.outbox.PutForContext(ctx, shot)
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,
"turnAttachment": status,
"hint": shotHint(status),
}, nil
}
// shotHint 是按附件状态给出的**如实**下一步提示(进模型上下文,因此措辞必须与事实一致:
// queued 时说「已经作为图片附件送出」,其余档说「图没进上下文,按路径读」)。
func shotHint(status string) string {
switch status {
case TurnAttachmentQueued:
return "This screenshot is queued as an attachment for the current turn and will be " +
"in your context on the next step (as the image itself, or as a vision/OCR description " +
"if your model cannot receive images). Use image-crop on the path above to read a " +
"region at full detail."
default:
return "This screenshot is NOT attached to the conversation (" + status + "). " +
"Pass the path to the vision, OCR or image-crop tool to read the page content."
}
}
// 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 ""
}