首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >chromeAgent 在沙箱里长大:一个浏览器自动化 Agent 的入网、派发与「人」的门口

chromeAgent 在沙箱里长大:一个浏览器自动化 Agent 的入网、派发与「人」的门口

原创
作者头像
OneCode
发布于 2026-10-08 10:38:33
发布于 2026-10-08 10:38:33
250
举报
文章被收录于专栏:ooderAgentooderAgent

把「每月去电子税局拉开票明细」这件两小时的活交给 Agent,需要跨过三道坎:怎么装进沙箱、怎么被 A2A 派发命中、怎么在 CA 登录那一步把人请回来。本文是一次完整的工程复盘——含 68 条实测判据与 3 个被测试抓出来的真实缺陷。

代码基线:ooder-biz-browser · ooder-browser-spi · ooder-test · ooder-a2a(2026-10-07 实测)

2026-10-08

01每月一号的那两小时

财务同事每月要干一件事:登录电子税务局,切到"开票明细查询",填起止日期,点查询,等表格出来,导出 CSV,再贴回内部的发票台账。全流程大概两小时,其中真正"有判断"的部分不超过五分钟。

这五分钟是登录——CA 证书、短信验证码、偶尔还有图形验证码。剩下的两小时是重复的鼠标动作。

平台里其实已经接了钉钉、有成这类系统的 OpenAPI,开票台账的写入早就自动化了。卡住的恰恰是"读"那一头:税务侧没有对外的开放接口,只有一个人用的 Web 页面。于是数据进来之前,必须先有人坐在浏览器前。

一句话概括我们要解决的问题:在"对方只有网页"这个前提下,把重复动作交给 Agent,把必须由人做的那五分钟留给人——并且让这两半在同一个协议里衔接,而不是靠人肉搬。

02为什么最后是「浏览器 Agent」

先说结论:这不是"选一个 RPA 工具"的问题,而是分层的问题。我们把候选方案按"底座 / 增强 / 兜底"分了三层,各自只解决自己那一段。

方案

它能解决什么

为什么不选它做底座

我们的定位

Selenium / WebDriver

生态最大、资料最多

要引入 driver 二进制与一整套依赖树;内网离线环境与 JDK 11 基线下成本过高

不选

Playwright for Java

自动等待、多浏览器、API 现代

实为 Node 驱动的封装,运行时仍要拉一份 Node 与浏览器包;跨源 iframe 体验好但代价大

按需升级

进程内自建 CDP

直接说 Chrome DevTools Protocol

要自己写 WebSocket 帧、事件路由、超时

底座

LLM 自愈定位(browser-use 一类)

页面改版后自动找元素

容易"幻觉成功"——找不到元素时可能自圆其说;财务数据不能赌

仅 waitFor 超时兜底

商业 RPA

流程编排、录制回放现成

不重造轮子,但要能嵌进我们的 A2A 协议才有用

适配,不重造

内嵌浏览器薄壳

人可以在同一个窗口里接管

它是"人工接管通道",不是驱动底座

保留为人工通道

所以底座是进程内自建 CDP:JDK 11 自带的 java.net.http.WebSocket 就够了,零第三方依赖、零 Node 常驻、零 driver 二进制。这不浪漫,但它同时满足了内网离线、JDK 基线和"产物必须是薄 jar"三条硬约束。

优先级铁律:开放接口永远优先于浏览器模拟。税局若有直连通道,就别爬页面;浏览器 Agent 是"对方只有网页"时的最后手段,不是首选。

03沙箱里长出一个 Agent:整体形态

平台有两种宿主:Studio L0(StudioNew.jar,靠 -Dloader.path 外挂业务 jar)和沙箱(SandboxServer.jar,端口 8081)。这次我们选择在沙箱里跑 chromeAgent,因为沙箱是"验证产物能否真跑起来"的地方,天然适合做端到端。

图 1 · chromeAgent 在沙箱内的形态:三个 bean 同处一个进程,Chrome 是唯一的外挂进程

两个模块:ooder-browser-spi(零依赖契约,只有端口与常量)和ooder-biz-browser(薄 jar,不可执行、没有 application.yml)。拆开是为了让"驱动可替换"这件事在编译期成立——今天用自建 CDP,明天换 Playwright,agent 代码一行不动。

04装载:三条通道,只有一条通

这是第一个坑。沙箱不是 L0。

通道

机制

能否让 agent 成为 A2A 执行端

适用

-Dloader.path

PropertiesLauncher 按目录挂外部 jar

可以

Studio L0

POST /api/test/deploy

收 jar → 编译 → 自定义 ClassLoader 装载

不行

产物验证

-Pagent-host 内嵌

Maven runtime 依赖打进 BOOT-INF/lib

可以

沙箱

沙箱的产物是普通 ZIP fat jar,没有 loader.properties,压根不解析 -Dloader.path。而"热装载"那条路更容易被误以为可行——它确实能把 jar 编译装载进去,但:

ooder-test/src/main/java/net/ooder/test/service/SandboxCompileService.java:22-26

代码语言:javascript
复制
private final Map<String, SandboxClassLoader> sandboxClassLoaders = new ConcurrentHashMap<>();
private final Map<String, Class<?>>            sandboxClasses     = new ConcurrentHashMap<>();
private final Map<String, URLClassLoader>      jarClassLoaders     = new ConcurrentHashMap<>();
// 装载只落这三个 Map;全模块搜不到任何 BeanDefinitionRegistry / registerSingleton / registerHandler

类进来了,但没进 Spring 容器、也没调 A2AProtocolService.registerHandler。它只能在 executeInSandbox 里被反射调一个无参方法。所以"热装载一个 agent"在这套架构下不成立。

于是接线点只能落在构建期:

ooder-test/pom.xml · agent-host profile

代码语言:javascript
复制
<dependency>
    <groupId>net.ooder</groupId>
    <artifactId>ooder-biz-browser</artifactId>
    <version>1.0.j11</version>
    <scope>runtime</scope>          <!-- 与 ooder-biz-finance 等既有的 B/C 包同一范式 -->
</dependency>

Windows 上的一条硬规则:运行中的沙箱进程会锁住 target/SandboxServer.jar,clean/repackage 直接失败。先停进程,再打包。(L0 同理)

05入网:两个注册点与四个「必须」

jar 装进去了,下一步是"被认出来"。平台里 agent 的注册分两层,两层都成功才算注册:

  • 内存目录(AgentContextManager):进程内 ConcurrentHashMap,重启即丢——决定"本进程能不能被本地消费";
  • 落盘名册(AgentRosterStore → {studio.instance.data.dir}/a2a-agents.json):持久,启动期由 @Order(0) 的种子器回灌——决定"重启后还在不在"。

chromeAgent 用的是两个 ApplicationRunner,顺序是硬约束:

图 2 · 启动期注册顺序:@Order(20) 必须晚于 @Order(10),否则执行端覆盖不了占位 handler

四个「必须」

#

必须

不满足会怎样(都是静默的)

M1

名册条目必须带 nodeId

normalizeAddressability 标 addressable=false 并 WARN;跨节点定向投递直接不可用

M2

执行端 @Order 必须晚于注册器

目录里有能力,但 TASK_REQUEST 只落到占位 handler,不报错

M3

包名必须落 net.ooder.*

jar 装进去了但组件扫描不到,没有任何日志

M4

claim.enabled=true

注册器与执行端都早退,GET /agents 看不到,任务无消费者

M1 是这次真踩到的:修复前那个沙箱实例的启动参数里没有 -Dooder.a2a.node-id,于是名册里的两条记录 nodeId 为空。表面一切正常——GET /agents 能看到 agent,本地也能派发——但"能被别的节点找到"这件事其实早就是残废的。所以我们把启动脚本固化了:

_deploy/sandbox/start-sandbox-browser.ps1(节选)

代码语言:javascript
复制
-Dooder.agent.host.enabled=true          # 激活白名单扫描 view + net.ooder
-Dooder.a2a.node-id=sandbox-8081         # M1:名册 addressable 的唯一来源
-Dooder.a2a.agent.namespace=browser
-Dooder.a2a.agent.claim.enabled=true     # M4
-Dstudio.instance.data.dir=...\data\studio-db   # 名册与消息库随实例隔离
-Dooder.a2a.store.dir=...\data\studio-db
-Dooder.browser.profile.base=...\browser-profiles

顺带说一句:AgentHostConfiguration 的排除项一共 8 条,net.ooder.browser.* 一条都没命中;而 net.ooder.studio.chat.a2a.* 也在扫描范围内——这意味着沙箱会在 agent-host 模式下顺带暴露 L0 的 A2A 运维面 /api/studio/a2a/*。这一步是白捡的:既不需要 MQTT,也不需要另起一个 L0 实例。

06一次调用的旅行

现在发一条消息。整个链路只有 5 跳,但每一跳都有"看起来成功其实没成功"的陷阱。

图 3 · 一次同步调用的五跳,以及"没人接"时的真实表现

调用形态我们认两种:扁平的 {op, ...},和 A2ARequest 产出的包裹形态 {action, parameters:{op, ...}}。本来以为这只是"兼容一下",结果它成了两个真实缺陷的产地(见第 8 节)。

还有一个 ops 面的缺口必须补。原来的 /api/studio/a2a/dispatch 走的是单向 sendMessage:只返回 messageId,答案要去 /messages 里翻;更麻烦的是,当请求方不是本节点的注册执行体时,对方的 TASK_RESPONSE 会被如实标成 UNDELIVERABLE——这不是 bug,是"单向 + 无回投通道"的必然结果。于是我们加了同步端点,且坚持走同一条生产协议链(sendRequest → pending future → completePendingResponse),不另写一套:

POST /api/studio/a2a/request

代码语言:javascript
复制
{
  "toAgentId":  "browser.web-automate",
  "parameters": { "op": "health" },        // 与 "payload" 等价;action 可省
  "headers":    { "replyNodeId": "sandbox-8081" },
  "timeoutMs":  20000
}

// 200 — A2A 层的失败也是 200,成败看 data(与 dispatch「接口成功≠链路成功」同口径)
{ "code": 200, "data": {
    "success": true,
    "result": { "ok": true, "driverPresent": true,
                "driver": { "id": "cdp-jdk11-20261007" }, "pendingGates": [] },
    "errorCode": null, "elapsedMs": 52 } }

07人的门口:两段式人工门

回到开头那五分钟。Agent 走到登录页,发现要 CA 证书——这时候它只有三个选择:放弃、硬闯、叫人。硬闯验证码既不可靠也不合规,所以只剩叫人。

关键在于怎么叫。最直觉的做法是在 handler 里死等,但 A2A 请求默认超时是 30 秒,而等人扫码通常几十秒到几分钟。死等的结果是:请求方先超时,拿到 TIMEOUT,编排方误判失败并可能重试——重复开浏览器、重复登录,甚至触发风控。

所以门被拆成两段:

图 4 · 两段式门:请求立即回执,人做完再放行;门的等待不占用任何 A2A 请求超时

门的状态机与否决语义

人在门里有三个选择,语义必须一一落到执行上,否则"点了取消但机器继续跑"就是事故:

图 5 · 门的四个终态;"否决"是唯一会让会话进入冻结的分支

字段名我们刻意对齐了平台既有的 HumanConfirmEventFactory(confirmId / phase / message / options / timeoutSeconds / waitingForUser),这样前端可以复用同一套门渲染;额外多给三个"让人看得见现场"的字段:screenshotPath · url · title / readyState。承载通道不同(我们是 A2A NOTIFICATION + x-human-gate 头,平台既有是 SSE human_confirm + /confirm),但语义同形——接不接引擎的 HUMAN 活动是产品决策,我们刻意保持不耦合,避免把浏览器 agent 绑死在流程引擎上。

一条不能商量的安全口径:CDP 调试端口 = 明文登录态。驱动只连 127.0.0.1、每会话独立 user-data-dir;薄壳脚本里的 -Cdp 默认关闭是刻意设计,不要为了"方便自动化"把它常开。

08验证:68 条判据与三个真实缺陷

这一节是整件事里我最想留住的部分。我们做了四个探针,加起来 68 条判据,全部实测通过:

探针

回答什么问题

判据

关键证据

run-jar-probe

装进去会不会被认出来(形态/包名/注解/顺序/SPI)

13

classpath 只用 jar;无 BOOT-INF/application.*;@Order(10/20);ServiceLoader 命中

run-probe

驱动真能操作浏览器吗

19

受控输入回显、表格抽取、上传/下载、跨源 iframe、Shadow DOM、否定用例

run-a2a-probe

A2A 派发与 human 门在协议层对不对

15

真实 A2AProtocolServiceImpl 本地闭环,无 MQTT、无 Spring 容器

verify-sandbox-a2a

接进沙箱后到底通不通

21

沙箱进程内真起 Chrome 跑完 open→act→extract→close + 全链路 UTF-8

三个被测试抓出来的缺陷

三个都不是"编译不过"那种显式错误,而是静默的行为错误——这也是为什么必须写断言而不是跑一遍看日志。

A2ARequest.toA2AMessage() 产出的 payload 是 {action:"human.resume", parameters:{action:"cancelled"}}。摊平时我们原本"顶层键优先",于是协议动作名 human.resume 把业务参数 cancelled 覆盖了。

后果:人在门里点"中止本会话",human.resume 依然把动作当成"放行",会话不冻结,后续自动操作照常执行。安全语义被静默吞掉。

修法:摊平时排除顶层 action;parameters 的键优先,顶层只补缺。

修缺陷 1 时引入的回归:顶层 action 被排除后,op 的兜底也没了(因为兜底读的是"摊平后的 action")。于是所有 parameters 里没写 op 的请求都掉到默认 op = health。

后果:发 capabilities 拿回来的是 health 的结果,success=true,没有任何报错——"消息到了但干的是别的事"。

修法:摊平前先把 wrapperAction 取出来留底,摊平后用它兜底 op。

(这条 1 分钟内被同一条测试抓住——这正是"用户故事测试"该有的样子:不是走一遍 happy path,而是逐条断言协议字段与安全语义。)

代码语言:javascript
复制
// A2ARequest.toA2AMessage(),修复前
.payload(Map.of("action", action, "parameters", parameters))

Map.of 的键值都不允许 null。新增的 /request 端点允许只传 parameters.op(op 才是业务语义,action 只是标签),于是 action 为 null → 抛 NullPointerException,且 message 为 null,调用方只能看到一个没有原因的 REQUEST_FAILED。

修法:改用 LinkedHashMap 构造 payload,允许 action 为 null;消费者按需回退到 parameters.op。

两个"测试侧"陷阱(别再踩)

  • PowerShell 的 $null -eq '' 是 false。我写的 if ($toAgentId -eq '') 在参数省略时没兜住,body 里发出 "toAgentId":"",服务端在 HTTP 200 里回了 code:400——于是"ping 没有响应",而 open 却正常(因为我给它显式传了空串)。看起来像产品 bug,其实是脚本 bug。改为 [string]::IsNullOrEmpty()。
  • 中文在 HTTP 回包里被解码成 Latin-1。抽取到的 CJK 表头 len=12(4 个字变 12)。先用 curl 把原始字节拿到手看十六进制(e7a5a8 e68dae e58fb7 e7a081 是正确的 UTF-8),确认服务端无罪,再改脚本:一律取原始字节自己按 UTF-8 解码,不要信 Invoke-WebRequest.Content 或 WebClient.Encoding 的自动判定。

一条通用判定法:遇到"编码/内容不对",先用 curl -o resp.bin 拿原始字节看 hex,一次就能分清"是服务端错了,还是我的解码器错了"。

9结语:把「不可能自动化」切成两半

回头看,这件事真正的收获不是"能用 CDP 驱动浏览器了"——那是已知技术。收获是把一件"不可能自动化"的事切成了两半:

  • 能自动的一半(导航、填表、查询、抽取、导出)交给 Agent,并且让它跑在沙箱里、被 A2A 派发、结果可回读;
  • 不能自动的一半(CA 登录、短信验证码)明确划给人类,并且用协议把"请人"和"放行"变成两条可编排的消息,而不是靠人喊一声。

切完之后,剩下的两小时变成:Agent 跑到登录页 → 发一条门通知 → 人花五分钟登录 → 点"已完成,继续" → Agent 继续把表格抽回来。人没有变少,但人的注意力只剩那五分钟。

三条工程口信: ① 装载形态决定一切:沙箱不解析 -Dloader.path,热装载不进容器——先确认宿主能吃哪种 jar,再谈注册。 ② "静默失效"比报错更贵:nodeId 空、@Order 反了、包名不在扫描范围、入参摊平覆盖——四个都是不报错的。所以每条都要写成断言。 ③ 人的门口不要阻塞:门是事件,不是同步等待;否决必须真的冻结。


OODER Studio · 工程实践系列 · 2026-10-08

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 把「每月去电子税局拉开票明细」这件两小时的活交给 Agent,需要跨过三道坎:怎么装进沙箱、怎么被 A2A 派发命中、怎么在 CA 登录那一步把人请回来。本文是一次完整的工程复盘——含 68 条实测判据与 3 个被测试抓出来的真实缺陷。
  • 01每月一号的那两小时
  • 02为什么最后是「浏览器 Agent」
  • 03沙箱里长出一个 Agent:整体形态
  • 04装载:三条通道,只有一条通
  • 05入网:两个注册点与四个「必须」
  • 四个「必须」
  • 06一次调用的旅行
  • 07人的门口:两段式人工门
  • 门的状态机与否决语义
  • 08验证:68 条判据与三个真实缺陷
  • 三个被测试抓出来的缺陷
  • 两个"测试侧"陷阱(别再踩)
  • 9结语:把「不可能自动化」切成两半
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档