feat(browser): B1 接线 + B2 薄层工具面(V5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-06 20:25:16 +08:00
1 parent ea150ceea9
commit f1fbc249fd
21 files changed
+3579

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
package browser
import "errors"
// 本包对外哨兵错误,一律用 errors.Is 判定;文案由调用方补齐上下文(AGENTS §10:
// 错误一律 %w wrap,禁裸 return err)。**不设包级可变状态**(除这些 var ErrXxx)。
var (
// ErrCDP 表示 CDP 层失败:WebSocket 连不上 / 命令返回协议错误 / 连接被打断。
ErrCDP = errors.New("browser: CDP 调用失败")
// ErrLaunch 表示启动本机浏览器失败:可执行文件拉不起来、进程立刻退出、
// 或者等不到调试端口就绪。
ErrLaunch = errors.New("browser: 启动本机浏览器失败")
// ErrNavigate 表示导航失败(CDP 报 errorText,或等待页面就绪超时)。
ErrNavigate = errors.New("browser: 导航失败")
// ErrEvaluate 表示在页面里求值失败(表达式异常 / 拿不到执行上下文)。
ErrEvaluate = errors.New("browser: 页面求值失败")
// ErrBlockedURL 表示目标 URL 被 SSRF 守卫拒绝(回环/私网/链路本地等)。
ErrBlockedURL = errors.New("browser: URL 被安全守卫拒绝")
// ErrScreenshotTooLarge 表示截图在两次尝试(PNG → JPEG)之后仍超过配置的字节上限。
// 超限**不截断**:半个 PNG 是坏图,模型读到的会是噪声(design/28 §3「超限如实
// 截断/缩放并标注」的落地口径 —— 能降编码就降,降不下来就如实报错)。
ErrScreenshotTooLarge = errors.New("browser: 截图超过字节上限")
// ErrNoSession 表示管理器已被关闭,或当前没有可用会话(不可用时不假装可用)。
ErrNoSession = errors.New("browser: 没有可用的浏览器会话")
// ErrUnavailable 表示这台机器上没有任何可用来源(没装 Chrome、也没有在跑的
// CDP 端点)。它由组合根在探测阶段判定,此处只用于「手里没有 dialer」这一态。
ErrUnavailable = errors.New("browser: 本机没有可用的浏览器来源")
)
// Reason 是「这次为什么没成功」的**分类原因码**:上层据此如实降级与记日志,
// 不许把它含糊成「失败」两个字(design/28 §6 坑 5)。取值稳定、可断言;人类可读
// 文案见 Message()。
type Reason string
// 分类原因码。
const (
// ReasonLaunched 表示会话由本机 Chrome/Chromium 新起(默认有头、持久 profile)。
ReasonLaunched Reason = "launched-local-chrome"
// ReasonRemoteCDP 表示会话复用了配置里显式给出的 CDP 端点(容器/手动实例)。
ReasonRemoteCDP Reason = "remote-cdp"
// ReasonIdleRecycled 表示上一次会话因空闲超时被回收(下一个动作会重新起)。
ReasonIdleRecycled Reason = "idle-recycled"
// ReasonLaunchFailed 表示本机浏览器没拉起来(可执行文件 / 端口未就绪)。
ReasonLaunchFailed Reason = "launch-failed"
// ReasonNoPageTarget 表示连上了浏览器却找不到可用的 page target。
ReasonNoPageTarget Reason = "no-page-target"
// ReasonBlockedURL 表示 URL 没过 SSRF 守卫。
ReasonBlockedURL Reason = "blocked-url"
// ReasonNavigateFailed 表示导航失败或等待就绪超时。
ReasonNavigateFailed Reason = "navigate-failed"
// ReasonShotTooLarge 表示截图超过字节上限(如实报,不交坏图)。
ReasonShotTooLarge Reason = "screenshot-too-large"
// ReasonClosed 表示管理器已关闭。
ReasonClosed Reason = "closed"
)
// Message 返回原因码对应的中文文案;未知码返回空串(调用方回落到错误的 Error())。
func (r Reason) Message() string {
switch r {
case ReasonLaunched:
return "已启动本机浏览器(默认有头 + 持久 profile)"
case ReasonRemoteCDP:
return "已连接到配置指定的 CDP 端点"
case ReasonIdleRecycled:
return "空闲超时,上一次会话已回收"
case ReasonLaunchFailed:
return "本机浏览器未能启动"
case ReasonNoPageTarget:
return "连上了浏览器,但没有可用的 page target"
case ReasonBlockedURL:
return "目标 URL 未通过安全守卫"
case ReasonNavigateFailed:
return "导航失败或等待页面就绪超时"
case ReasonShotTooLarge:
return "截图超过配置的字节上限"
case ReasonClosed:
return "浏览器管理器已关闭"
default:
return ""
}
}