Session 模型
面向内部工具的 UX 优先 session 模型 —— 仅做簿记,不做 consent。首 action 自动创建、tabId 作主键、多 session 语义、 以及各种终止路径。
为什么需要 Session 模型
BUA 是内部工具。Session 的角色是调试器挂载生命周期 + 审计的簿记,不是 consent token。没有 TTL、 没有 approval 步骤、没有 “Trust this site” 流程。Agent 在某个 tab 首次发起 action 时 session 随即被自动创建, 在生命周期事件触发(tab 关 / 用户 idle / 用户停止 / 被加入 blocklist)时结束。
扩展仍然强制一条硬边界:domain blocklist,每次 action 前检查。其它一切记录(审计日志)但不拦截。
Session 结构
interface ActiveSession {
tabId: number; // 主键
domain: string; // 当前域名(导航时静默更新)
startedAt: number; // 创建时间戳(ms)
lastActionAt: number; // 最近一次 action 派发时间戳
actionCount: number;
}
持久化在 chrome.storage.local 的 activeSessions 键下。写操作通过内存 promise chain (withSessionWriteLock) 串行化
—— chrome.storage.local 无 CAS,并发改会互相覆盖。
Session 身份
Session 的主键是 tabId,不是 domain。同一个域名在多个 tab 上打开时可以同时存在多个 session。这是有意设计
—— 让 agent 在自己专属 tab 上工作(BUA window 里),用户继续在主浏览器的同域 tab 上正常浏览,互不干扰。
CDP 命令按 tabId 下发,一边的操作永远不会影响另一边。Popup 列出每个活跃 session(连同 tabId),用户可以精确停掉 其中一个,不牵连同域的其它 session。
Agent 省略 tabId 时的消岐义
大多数 action (click、type、extract 等) 都支持可选 tabId。不传时扩展按以下顺序挑一个:
- 任意 BUA 拥有的 session(agent 自己开的 tab 永远是安全目标)。
- 用户拥有的 session,仅当它是唯一候选。
- 否则报
session_not_found,并把候选清单带在 message 里,agent 应当改用显式tabId重试。
Prompt 里明确要求 agent 追踪 open_tab 的返回值 tabId,后续调用都带上。不传 tabId 只是对单 session 情况的便利,
不是让扩展猜。
Session 如何创建
两条路径,都不需要用户授权:
| 路径 | 触发方式 | 落地 tab |
|---|---|---|
| 自动 —— 首次 action | Agent 对尚无 session 的 tab 发任意 action | 那个 tab(无论在哪) |
显式 —— open_tab | 浏览器 subagent 调 open_tab(url) | 默认落 BUA window 里的新 tab |
首次 action 自动创建
当 agent 对 tabId=T 的 domain=D 派发 action 时,action-dispatcher 的流程:
- 解析
tabId+domain(从 action 参数 + 实时 tab 状态) - 检查
isDomainBlocked(domain)—— 命中即返回domain_blocked(终止),发domain_blockedWS 事件,不创建 session - 调
ensureSession(tabId, domain):- 尚无 session →
startSession+SessionEvent { type: "start" }→ 触发session_startedWS 事件 - 存在同域 session → 复用
- 存在不同域 session(tab 中途导航)→ 静默更新
session.domain为新值,不发事件、不发 WS —— 审计日志会记录每次 action 的当前 URL,变更可追溯
- 尚无 session →
- 获取 per-tab 锁
- 挂载
chrome.debugger(如未挂) - 执行 CDP action
recordAction(tabId)—— 累加actionCount+ 刷新lastActionAt
v3 里的整个”consent”步骤消失了。授权是隐式的;accountability 在审计日志里。
open_tab(agent 发起)
Agent 调 browser({ action: { type: "open_tab", url: "https://…" } })。Dispatcher:
- 检查 blocklist(规则同自动创建)
- 在 BUA window 里开新 tab(默认最小化 + 非聚焦 + 对用户不可见)。
focus: true会改成开在用户当前聚焦窗口 —— 仅在用户明确要求”给我看看”时使用 - 调
startSession(domain, newTabId)→ 触发session_startedWS 事件 - 返回
{ tabId, windowId, domain }—— subagent 记下tabId用于后续所有 action
BUA 窗口
BUA 窗口是扩展为 agent 主动浏览专门创建的独立 Chrome 窗口,目的是让 agent 可以在已登录页面上操作却不打扰用户的主浏览器。
不变量:
- 默认隐藏 —— 用
chrome.windows.create({ focused: false, state: "minimized" })创建。永不抢焦点;用户通过 popup 的 Show 按钮按需查看 - 懒创建 —— 只有 agent 第一次
open_tab时才创建;agent 若永不自开 tab,则永远不存在 BUA 窗口 - 自动收窗 —— 当 BUA 窗口里最后一个 tab 关闭时窗口也随之关闭,避免遗留的最小化窗口堆积
- 窗口 id 持久化 —— 存在
chrome.storage.local的buaWindowId键里以扛 SW 重启;监听chrome.windows.onRemoved在用户手动关窗时清除记录
路由规则:
- Agent 的
open_tab→ BUA 窗口,tab 以active: false打开,自动建 session - Agent 的
open_tab({ focus: true })→ 用户聚焦的窗口 +active: true。仅在用户明确要求”展示结果”时用
Session 如何结束
六种终止原因,一条清理路径。所有路径都汇聚到 endSessionByTab(tabId, reason) 或 endAllSessions(reason),触发
SessionEvent { type: "end" } → 自动转发为 session_ended WS 事件。
| 原因 | 触发 |
|---|---|
tab_closed | chrome.tabs.onRemoved —— 一次性任务的正常路径 |
user_stopped | Popup 单 session “Stop now”,或 Chrome 黄条 “Cancel” → onDetach |
system_idle | chrome.idle 报告全机 30 min 无输入 |
screen_locked | chrome.idle 报告屏幕被显式锁定 |
domain_blocked | 用户把该域加入 blocklist → endAllSessionsForDomain fan out |
global_stop | Popup 红色 “Stop all” → endAllSessions fan out |
extension_reload | SW 被卸载 / 扩展更新;一般不显式上报 —— session 在下次 SW 唤醒时 reconcile |
六者共享同一套清理:detach chrome.debugger(幂等)、关闭 BUA-owned 宿主 tab 防止最小化 tab 堆积、BUA 窗口若空则收窗、
拒绝该 tab 任何在途 action 为 session_not_found。
两个”用户在场性”原因 —— system_idle 与 screen_locked —— 是有意拆开的,而不是合并成一个:它们代表不同的安全姿态,
审计日志值得分开:
screen_locked—— 用户主动锁屏。锁屏后继续 agent 控制等同于在不同的信任上下文里运行,扩展在chrome.idle.onStateChanged("locked")触发时立刻撤销所有会话system_idle—— 整机在检测间隔内(默认 30 分钟)没观察到输入。软信号 —— 用户可能走开,也可能在看内容不敲键盘。 这条兜的是”被遗忘会话”的风险,不会对短暂停顿过度敏感
两者都走同一个 idle-session-guard 模块,都走和其他终止原因一样的清理路径;审计日志保留具体命中的那一个,
以便 UI / 值班据此给出不同的修复提示。
debugger-detach 那类 bug,现已封闭
chrome.debugger.onDetach 可能因扩展控制不到的原因触发(用户点黄条 Cancel、Chrome 撤销挂载、DevTools 抢占)。v3 里的
handleDebuggerDetached 调了 endSessionByTab 但没清扩展内存的 attached: Set<number>。下一次 attach() 会看到
残留集合、按”已挂载”早退,后续所有 CDP 命令静默挂住。v4 的实现在这条路径上先调 debuggerController.detach(tabId)
—— detach() 是幂等的,双调也安全。
用户可见表面
两个表面保证用户随时在控制:Chrome 原生调试条(系统级、不可屏蔽)和扩展 popup(活动监视器 + 全局急停)。两者在后台 走同一条清理路径。
Popup —— Stop all
Popup 的红色 “Stop all” 按钮会一次性结束所有 tab 上的所有活跃 session,并发 global_stop WS 事件,让后端在
per-session session_ended 到达前就短路所有在途请求。
Popup 的 session 列表里仍保留单 session 的 Stop 按钮用于精准撤销。两条路径都走 sessionManager.endSessionByTab /
endAllSessions → 同一条 SessionEvent 总线 → 同一条 WS 转发。
不弹确认 —— 用户按下去就是已决定了。
Chrome 调试条
只要扩展把 chrome.debugger 挂到一个 tab,Chrome 就会注入一条黄色横幅写 “Zapvol Browser Bridge is debugging this
browser” + 一个 Cancel 按钮。这个横幅无法屏蔽 —— Chrome 有意做得醒目。
点 Cancel 触发 chrome.debugger.onDetach,扩展先清内存 attached 集合,再按 user_stopped 结束 session —— 走和 popup
Stop 相同的清理路径。
业内所有本地浏览器自动化方案(Anthropic computer-use demo、Browser Use 本地模式、各种扩展化自动化)都要面对这条
横幅。这是在用户自己的 profile 上做真实输入自动化的必要代价。内部部署时 IT 可以通过 Chrome 启动参数
--silent-debugger-extension-api 在 OS 层隐藏黄条 —— 这是部署侧的问题,不是扩展侧的。
错误处理契约(agent 侧)
Agent 应当把以下错误视为当前 action 的终止错误——不做静默重试:
| 错误码 | 含义 | Agent 应该 |
|---|---|---|
domain_blocked | 目标域在 blocklist | 停止本 plan;不要换 tab / 换 action 重试 |
session_not_found | 目标没有活跃 session,或多 session 歧义 | 显式传 tabId,扩展不会猜 |
tab_not_found | Tab 已关闭 | 停止,或先 get_tabs 换一个 tab |
element_not_found | selector 或活跃 uid 在当前 DOM 上匹配不到 | 重新 extract 页面;挑一个不同的目标;不要重试同一个 |
element_stale | 给的 uid 已不在缓存(页面导航 / 缓存被清 / 本次还没 extract) | 再调一次 extract 重建缓存,用新 uid 重试 |
timeout | Action 超时 | 瞬态问题可配合 wait_for 重试一次,否则停止 |
debugger_attach_failed | DevTools / 另一个调试器占用 | 请用户关 DevTools;不要重试 |
invalid_action | Schema / 未知 action,或 evaluate 表达式抛异常 | 读 message 改请求;不要盲重试 |
internal_error | 意外失败 | 停止;把错误消息报给用户 |
这份契约写进了 browser 工具的 prompt(见 packages/backend/src/agent/tools/browser.tool.ts),每次工具加载时模型
都会看到。
审计日志 (Audit log)
每次 session 的 start 和 end 都追加到 chrome.storage.local 的 sessionHistory 键,上限保留最近 1000 条。
Options 页以倒序表格形式渲染:
- When —— 时间戳
- Event ——
START或END(按颜色区分) - Domain + tabId
- Detail ——
END行额外显示结束原因、持续时长、action 数
用户可一键清空(带确认对话框)。日志主要本地存储,后端同时收到 session_started / session_ended /
domain_blocked / global_stop / tab_closed 这几个 WS 事件用于运营观测。
设计说明:审计模块 (session-history.ts) 订阅 session-manager 的事件总线 (onSessionEvent),而不是被各个
端点显式调用。这样 session-manager.ts 不掺杂审计关注点 —— 如果日志格式要改,只碰一个模块。所有终止路径都走同一条
总线,审计日志自然就捕获了所有路径;background.ts 里另有一个 WS 转发订阅,服务端的视图也一并保持一致。