青雲的博客

Article

录一段网页操作给 Agent:打的字从四个地方漏进了文件

· 17 分钟阅读

9 月 22 日发布的 Pilion 0.1.4,更新日志的第二条写着:密码与一次性验证码从不被录下,这类输入只留一条「需要我」,回放到这一步会暂停,等你填完再继续。

9 月 24 日发布的 0.1.5,修复一栏里有四条,讲的都是你打的字怎么漏进了录制文件,密码和验证码也在其中。

Pilion 是我做的一个桌面浏览器,给 ACP Agent 用。左边是标签和工作记录,中间是网页,右边通过 ACP 接任意 Agent,本地的 Claude Code、Codex、Gemini CLI、Orca,或者 SSH 那头的远端 Agent。浏览器通过 MCP 把自己的标签页交给 Agent 操作,动手之前可以要求审批,人随时可以接管。0.1.4 加的录制,是让人把一段网页操作做一遍,交给 Agent 提炼成一份技能,以后同样的事 Agent 直接回放,不必每次从头摸索。

录制只给人,提炼不许造步骤

这个功能一开始就定了几条线。

第一条,只有人能录。MCP 里没有录制这个动词,只有 browser_skills_list 和 browser_skills_play,设计文档里的原话是:这是首要防线,不是加检查,是不给这个能力。录制脚本是 Pilion 自带的固定源码,放在页面看不见的隔离世界里运行,只收 isTrusted 为真的事件,页面的 JavaScript 伪造不出人的点击。

这里有个坑:Chrome 调试协议 Input.dispatchMouseEvent 派发出来的事件,isTrusted 也是真。Agent 自己的点击、回放时播放器的点击,都会被录进去。所以录制、Agent 任务、回放三者互斥,录制期间所有浏览器工具直接拒绝,理由写「用户正在录制」,界面上禁用之外,主进程也拒绝。录制时网页四周有一圈 2 像素的红框,画在 Pilion 自己的界面层里,不进页面,不会出现在截图里,页面也伪造不出一个假的。

第二条,提炼不许造步骤。提炼在一个专属会话里跑,那一轮不给浏览器工具,Agent 只读轨迹文本,交回一份技能文档。Pilion 拿到之后逐步对账:每个动作步骤,都必须在轨迹里消耗掉一条同类型、同目标的记录,一条记录不能被复用成三步;输入的值必须和录下的一致,或者换成占位符;要打开的地址必须在轨迹里出现过;只有「需要我」和旁白可以自由插入。任何一条对不上就不保存,界面指出是第几步找不到依据。

设计文档里写了为什么要这么严:否则提炼就成了「Agent 可以写出一串你没做过的操作,然后请你批准」,而人批准时只会扫一眼。同样的理由,技能库里的散文可以随便改,步骤只能删、排、改值、插「需要我」,没有新建步骤的按钮。

第三条,回放不开新路。播放器不实现「按选择器点击」,它是主进程里又一个工具调用方,和 Agent 走同一条路:每一步先重新观察页面,用纯函数把录下的目标匹配到这一次观察里唯一的那个元素,再发起点击。审批、审计、指纹校验、停止和接管,全部沿用。录制时也不存元素句柄:句柄绑着这一次运行的标签、这一个文档的版本和 CDP 的节点编号,换一次页面就作废,存它等于存一个文件描述符的编号。

Agent 第一次回放某个技能要审批一次,详情里列出全部步骤,完全访问模式下也问。这是对「完全访问不弹审批」的有意例外:一份技能是一整捆预先授权的副作用。

录制到回放:人在隔离世界里的脚本录下可信事件,写进只增不改的事件日志,投影成轨迹;Agent 在没有浏览器工具的专属会话里提炼,Pilion 对账后由人决定保留;任何 ACP Agent 通过 MCP 列出和回放技能,首次回放审批一次,播放器和 Agent 走同一条观察、匹配、点击的路径

归一化做得太早

第一期的录制器收到页面事件,就地归一化成带类型的步骤:指针按下和点击合成一次点击,一连串输入合成一个最终值,原始的事件流不落盘。

这样存下来的东西很干净,丢掉的东西也很具体。打字的过程没了;iframe、拖拽、右键只剩一个「需要我」;滚动、悬停、前进后退刷新、富文本编辑器里的输入、停顿多久,没有对应的步骤类型,直接消失。第一期的待办里记着三个「录制保真缺口」:iframe 里的输入仍被录下,前进后退刷新不产生步骤,富文本输入录不到。第三期的设计文档认定,这是同一个病的三个症状:归一化做得太早。

第三期把产物改回过程。按下录制之后发生的一切,都进一份只增不改的 events.jsonl,它是唯一的真相;原来那份带编号步骤的 trajectory.md,降级成从日志算出来的投影。投影必须是纯函数,同一份日志算多少次结果都一样,所以采集时解析出的目标也写进日志,重算投影不必重新观察页面。日志和轨迹对不上时,日志赢:手改过的轨迹步骤会被按日志重算覆盖。更新日志里「文件是普通 Markdown,可以直接改」这句话,范围也收窄成只指提炼出来的技能文件。

提炼时,Agent 除了轨迹,还能读到一段渲染过的过程:滚动了几次,在哪停顿了几秒,一个字段改了几遍。这些只是上下文,用来判断技能什么时候该用、有哪些前提和坑;步骤仍然只能从轨迹里挑,对账的证明一点没变。

左边是第一期:原始事件进录制器就地归一化,打字过程、滚动、前进后退、富文本输入在这一步丢掉;右边是第三期:所有事件先进只增不改的 events.jsonl,再由纯函数投影成轨迹,前进后退变成导航步骤,富文本输入变成「需要我」,过程时间线另交给提炼

打的字从四个地方漏出去

日志记得越全,能漏的地方也越多。密码框本身是最容易防的。录制脚本在页面里看到 type="password" 或者 autocomplete 带 one-time-code 的输入框,只发一条「需要我」,值和长度都不发。0.1.4 发出去的时候,防住的也只有这一处。

页面摘录

录制每记下一个页面,会顺带存一段页面上的文字,最多 2000 字,供之后提炼时参考。这段文字取自浏览器的可访问性树,而浏览器把输入框的值也作为文字挂在框下面:验证码原样在里面,密码是一串圆点,圆点的个数正好是密码的长度,富文本编辑器里写的字也在。只要打完字之后页面改了地址(单页应用常这样),或者后退回到这一页(浏览器会把验证码框的值填回去),这些内容就会作为新页面的摘录写进文件,提炼时还会发给 Agent。

修法是给录制单独做一个取文字的方法,节点和顺序不变,只跳过人能往里打字的地方:一段字自己或祖先可编辑,或者是文本框、搜索框、数字框,就不要;标题的名字如果取自 aria-labelledby,或者子树里有这类节点,整条不要。代价是页面上原本就有的编辑区内容,比如一份草稿,提炼时也看不到了。Agent 自己读网页时用的还是原来的方法,看到的内容不变。

页面自己的回显

可访问性树上看不出来的,是页面把人打的字另外写成普通文字。shadcn/ui、HeroUI 的验证码输入,把每一位单独画在一个方框里,上面盖着一个透明的输入框;编辑器旁边有实时预览;搜索结果页写着「……的搜索结果」。

框里的值就是上一步跳过的那些字,于是拿它们去比留下的每一行:整行等于某个至少两个字的值,或者含有某个至少四个字的值,或者连着几行都只有一个字、拼起来正好是某个值,这几行都不要。一个字的值不拿来比,免得数量框里的「1」撞掉一大片正常的行。代价是正文里碰巧和填过的内容相同的行也不记了,比如搜索结果页上重复搜索词的那些行。

元素的名字

每个步骤要记下目标元素的名字,回放时靠它找元素。名字来自可访问性树,而按钮、链接、卡片这类角色,名字取的是元素里的文字。点一下包着富文本编辑器的卡片,或者点编辑区里的链接、@提及,编辑器里打的字就成了这个元素的名字,写进录制文件,还出现在步骤列表和回放前请人确认的摘要里。包着密码框或验证码框的元素也一样:名字里带着验证码本身,或者一串个数等于密码长度的圆点。

修法是这类元素的名字一律扣下,显示为「[名称已隐去,含富文本]」。扣下名字的步骤,回放时既不按名字也不按指纹去匹配,而是停在这一步,说明是哪一步;由 Agent 发起的回放,会把这一步交还给 Agent。以前,包着编辑区的元素要编辑区里的字和录制时一模一样才能回放;现在回放到这里一定会停。

「显示密码」之后

很多登录框旁边有个「显示密码」按钮,按下之后,密码框的 type 从 password 换成 text。这之后只要再打一个字,整段密码就以明文记进录制文件,成为一条普通的「输入」步骤。

修法是一个输入框只要被录制脚本看到过是密码框,这一页里就一直当密码框处理;脚本开始时、人每按下一次鼠标或按键时,都先把页面上的密码框过一遍,先按「显示密码」再去点框也认得出来。autocomplete 标着 current-password、new-password 的输入框,不论 type 是什么,也按密码处理。代价是网页常拿 new-password 来关掉普通字段的自动填充,这类字段本身不是密码,现在也只能留一条「需要我」。

你打的字经四条路漏进录制文件:页面正文摘录、页面把字另写成普通文字的回显、按内容取名的元素名、「显示密码」切成明文之后的输入框;每条路旁边是 0.1.5 的修法、付出的代价,以及仍然认不出来的形状

那句话变成了一张例外清单

0.1.5 之后,「密码与一次性验证码从不被录下」后面跟着一张已知例外的清单,写在 docs/architecture.md 里:

  • 元素名这条路上,封闭影子根里的东西页面脚本看不进去;只靠 CSS -webkit-user-modify 变成可编辑、没写 contenteditable 的区域,脚本认不出;隔了一层的 aria-owns 挂进来的编辑区和密码框,可访问性树算进了名字,脚本看不到。把验证码每一位画成普通文字的组件,外面如果再包一层按内容取名的卡片,卡片的名字里仍带着验证码。
  • 回显这条路上,显示时改了样子的比不出来,比如三位一组、中间加空格;不到四个字的值夹在一整行别的字里;另写的时候框里已经没有这个值,或者框根本不在可访问性树里;把密码明文另外显示出来的。
  • 脚本从没见过是密码框的明文框:录制开始之前就已经切成明文的密码框,或者「显示密码」不是改原来那个框的 type,而是换上了一个新的明文框。

录制文件是权限 0600 的明文,按构造不含密码和一次性验证码,但确实含有键入的其他内容,邮箱、搜索词,还有页面标题和正文摘录,和浏览记录一样敏感。

清单的最后一项不属于前面任何一条路,到现在也没有修:地址本身。网页把一次性验证码或登录令牌放进地址里时,用 GET 提交的验证码表单、登录邮件里的魔法链接、OAuth 回调带回的 code 或 #access_token,页面条目、导航和每一个步骤的地址,都原样记了下来。

Keep Reading

相关文章

评论