浏览器控制 · 会话、并发与安全¶
本文说明四件相互关联的事:浏览器会话如何绑定到对话(隔离模型)、并发请求如何串行化、会话如何随空闲被回收、以及围绕鉴权/授权/归属/截图/eval 的安全边界。
相关代码:ethan/browser/session_map.py、ethan/browser/auth.py、ethan/browser/screenshot.py、ethan/browser/http_route.py、ethan/tools/builtin/browser.py、ethan/browser/hub.py。
1. 会话绑定模型:每个对话隔离自己的浏览器会话¶
Ethan 是长驻、多渠道的:同一时刻可能有飞书里的多个用户、Web 端的多个标签页在对话。如果所有对话共享同一批浏览器标签,A 的操作就会踩到 B 正在看的页面。因此采用会话绑定(per-conversation)模型:
- 每个 ethan 对话(
session_id)维护自己的一组 browser session。 session_map.py维护browser_session_id → {ethan_session_id, last_active}的映射。- 对话的
session_id通过ContextVar(ethan/core/context.py的ETHAN_SESSION_ID)注入到工具运行上下文;Web 的/chat路由与飞书事件处理在创建 Agent 前调用set_session_id(...)。
flowchart TD
subgraph 对话A["ethan 对话 A"]
TA["browser_session create"]
end
subgraph 对话B["ethan 对话 B"]
TB["browser_session create"]
end
TA -->|"bind(bs_a1, A)"| Map["SessionMap<br/>bs_a1→A<br/>bs_a2→A<br/>bs_b1→B"]
TA -->|"bind(bs_a2, A)"| Map
TB -->|"bind(bs_b1, B)"| Map
Map -->|"list_for(A)"| LA["[bs_a1, bs_a2]"]
Map -->|"list_for(B)"| LB["[bs_b1]"]
LA -.隔离.- LB
SessionMap 提供:bind(create/attach 后登记归属)、touch(每次操作刷新活跃时间)、unbind(release/close 后移除)、list_for(ethan_session_id)(列出某对话拥有的 browser session,用于归属门禁与过滤)、idle_sessions(ttl)(列出超过空闲时长的 session)。
2. 并发控制:单进程 + per-session 串行锁¶
为什么是单进程¶
整个子系统建立在 ethan 以单进程 uvicorn 运行这一前提上。原因不是性能,而是正确性:
- BrowserHub 是进程内单例,持有唯一一条扩展 WebSocket。多 worker 下扩展只能连上其中一个 worker 的 socket,其余 worker 调用浏览器工具时根本找不到连接。
- 同样地,基于内存
Future的 consent 授权流、APScheduler、飞书监听、心跳等也都依赖单进程共享内存。
浏览器是单一物理资源——开多少个 worker 也还是那一个 Chrome。多 worker 解决不了"两个对话争用同一浏览器"的问题,只会让"谁持有连接"变得更乱。完整论证见设计决策记录第 11 节 Q2。
并发靠协程,串行靠锁¶
单进程下,asyncio 事件循环天然并发处理多个请求。真正需要约束的是"同一个浏览器 session 内的页面操作不能交错":
- Hub 对
pages.*且带browser_session_id的调用,按browser_session_id取asyncio.Lock串行执行(见传输层与协议第 6 节)。 - 同一 session 内页面操作排队,避免 CDP 命令交错踩页面状态。
- 不同 session 之间并行,互不阻塞。
- session 管理类调用(
sessions.*/tabs.*)不加锁。
3. 生命周期:空闲 release(保留标签,不杀页面)¶
ethan 对话是长驻的,没有明确的"结束"信号。若不回收,Chrome 里的 TabGroup 会越积越多。session_map.py 用一个后台扫描任务处理:
flowchart TD
Start["start_idle_sweep()<br/>(在 FastAPI lifespan 启动)"] --> Loop["每 5min 扫描一次"]
Loop --> Clean["先清理过期截图 (cleanup_shots)"]
Clean --> Conn{"扩展已连接?"}
Conn -->|否| Skip["跳过 session 回收<br/>(断连时状态无意义)"]
Conn -->|是| Idle["idle_sessions(ttl=30min)"]
Idle --> Release["对每个空闲 session<br/>调 sessions.release<br/>(放掉控制权, 保留 tab)"]
Release --> Unbind["session_map.unbind"]
Skip --> Loop
Unbind --> Loop
- 空闲阈值
IDLE_TTL_SECONDS = 30 * 60(30 分钟),扫描间隔_SWEEP_INTERVAL = 5 * 60(5 分钟)。 - 空闲后执行的是 release(放掉控制权但保留用户标签),不是 close(杀标签)。理由:用户可能还在看那个页面,直接关闭是破坏性操作。真正的 close 只在 Agent/用户显式调用
browser_session(action="close")时发生。 - 用户回头继续对话时,Agent 重新
create一个 session 即可。 - 截图清理与扩展是否连接无关,总是执行(见第 6 节)。
4. 安全边界总览¶
flowchart TD
Req["来自任意渠道的浏览器工具调用"] --> Auth1["① WS 连接鉴权<br/>token 必须有效 (建立连接时)"]
Auth1 --> Consent["② 会话级一次性授权<br/>本对话首次 browser 调用需用户批准"]
Consent --> Owner["③ 会话归属门禁<br/>session 必须属于当前对话"]
Owner --> Channel["④ 渠道硬策略<br/>(如飞书非主人禁 side_effect)"]
Channel --> Exec["执行 CDP 操作"]
Exec --> Mask["⑤ 输出 secret 掩码<br/>(mask_text)"]
Exec --> Shot["⑥ 截图落专属目录 + 定时清理"]
五道关卡逐一说明如下。
5. 鉴权、授权与归属隔离¶
① WS 连接鉴权¶
扩展建立连接时首帧必须携带合法 token(复用 ethan web token 体系)。失败直接 close(4001)。详见传输层与协议第 2 节。
② 会话级一次性授权(consent)¶
auth.py 实现"一个对话内首次调用任意 browser 工具时请求一次授权,批准后该对话后续所有 browser 操作(含 eval)放行,不再追问"。
- 工具基类
consent_check()调用_consent_desc():若当前对话is_authorized(get_session_id())为真则返回None(放行),否则返回授权说明字符串,触发 ethan 既有的 consent 流程(Web 弹窗 / 渠道交互)。 - consent 通过后,工具
run()开头调用mark_authorized(get_session_id())记下该对话已授权。 - 这样设计是为了平衡安全与可用:既不至于"每点一下都要批一次",也不会因为某个对话授权了而污染其他对话——授权状态按
session_id隔离。无session_id的场景(如无会话的本地调用)不施加会话级门禁,交由渠道硬策略处理。
③ 会话归属门禁(防跨对话操控)¶
授权解决的是"能不能用浏览器",归属解决的是"能不能用这个 session"。browser.py 的 _call() 在把任何带 browser_session_id 的调用下发到 Hub 之前,先做归属校验:
# 伪代码,见 ethan/tools/builtin/browser.py
def _require_owned(browser_session_id):
owned = get_session_map().list_for(get_session_id())
if browser_session_id not in owned:
raise BrowserError("该 browser session 不属于当前对话,拒绝操作",
code=ERROR_CODE["session_not_found"])
- 这道门禁在
_call()入口统一收口,一处覆盖rename/release/close以及全部tab.*/page.*操作。 create/attach_current不带既有browser_session_id(新建后才bind),天然绕开门禁。session_list的返回也按当前对话过滤(_filter_owned_sessions),不向某对话泄漏其他对话/用户的 session id。- 效果:即便对话 B 已通过自己的 consent 授权,也无法操作对话 A 创建的 session——跨对话操控在触达 Hub 之前就被拒绝。
tab_user_list列出的是用户未被任何 session 接管的原生标签(供挑选可接管对象),不属于"别人对话的 managed session"。由于attach只能把标签纳入当前对话自己拥有的 session,跨对话操控这条路已被堵死;user_list仅是标签可见性,对任意已授权 Agent 一致。
④ 渠道硬策略¶
ethan 既有的渠道守卫仍然叠加。例如飞书在认主人后,非主人对 side_effect=True 工具一律直接拒绝。三个 browser 工具均声明 side_effect = True,因此自动受此策略约束。
⑤ secret 掩码¶
工具输出经过 ethan 既有的 mask_text 安全网,把上下文中出现的已知 secret 真值替换为掩码,防止页面内容/eval 结果把密钥回流进模型上下文。
6. 截图:落盘、回传与清理¶
CDP 截图返回 base64。为了让"任意渠道都能拿到图",采用落盘 + 路径而非内联 base64:
flowchart LR
Shot["page.screenshot → base64"] --> Save["save_screenshot()<br/>解码 base64<br/>(兼容 data:image 前缀 + 嵌套 screenshot.data)"]
Save --> Dir["写入 user_data_dir/browser-shots/<br/>shot-<时间戳>.png"]
Dir --> Lark["飞书: send_lark_image(path)<br/>(只认本地路径, 零改动复用)"]
Dir --> Web["Web: GET /api/browser/shot/{name}<br/>(防目录穿越: 仅 shot-* 前缀)"]
Sweep["idle sweep (每5min)"] --> Cleanup["cleanup_shots()"]
Cleanup --> Age["删超过 30min 的文件"]
Cleanup --> Cap["按数量上限 (200) 删最旧"]
设计要点:
- 为什么不内联 base64:飞书图片通道
send_lark_image(chat_id, image_path)只接受本地文件路径,这是硬约束;落盘后飞书零改动复用,Web 侧加一个文件路由即可,从而"渠道无关"真正成立。 - 为什么不用
/tmp:macOS 默认不自动清理/tmp,Docker 容器内更没有任何自动清理。因此落到 ethan 数据目录下的browser-shots/专属目录,自管清理。 - 为什么不"用完即删":文件需要存活到飞书上传完成(数秒级)。因此用"按龄清理 + 总量上限"而非即时删除:保留
30min(_MAX_AGE_SECONDS),总量上限200(_MAX_FILES)个,由 idle sweep 顺带清理。 - Web 文件路由防穿越:
http_route.py的/api/browser/shot/{name}拒绝含路径分隔符、且强制shot-前缀,只允许读取截图目录内的文件。 - 隐私:截图可能含敏感页面,只回传路径/由渠道渲染,不进模型上下文;配合定时清理与总量上限限制留存。
7. eval 的权限边界¶
page_eval 可在页面执行任意 JavaScript,是权限最高的能力:
- 它不单独逐次弹窗授权(否则与会话级授权设计冲突),而是被会话级一次性授权覆盖——本对话首次任意 browser 操作授权后,
eval随之放行。这是有意的折中:既然用户已授权该对话操作浏览器,逐次 gateeval会让 Agent 寸步难行。 - 但它仍受渠道硬策略(飞书非主人禁)与 secret 掩码约束。
- 使用准则(写入技能文档):仅在任务确需时使用,不要对不可信页面执行无关脚本。
8. 关键常量速查¶
| 常量 | 值 | 位置 | 含义 |
|---|---|---|---|
DEFAULT_REQUEST_TIMEOUT |
30s | protocol.py |
单次 RPC 超时 |
SESSION_SCOPED_PREFIX |
"pages." |
protocol.py |
需要 per-session 锁的 method 前缀 |
RPC_VERSION |
1 | protocol.py |
协议版本(auth_ok 携带) |
IDLE_TTL_SECONDS |
1800s (30min) | session_map.py |
session 空闲回收阈值 |
_SWEEP_INTERVAL |
300s (5min) | session_map.py |
空闲扫描间隔 |
_SNAPSHOT_MAX_CHARS |
30000 | browser.py |
snapshot 输出硬截断阈值 |
_MAX_AGE_SECONDS |
1800s (30min) | screenshot.py |
截图文件保留时长 |
_MAX_FILES |
200 | screenshot.py |
截图文件数量上限 |
| 心跳间隔 | 20s | ws-client.ts |
扩展 ping 周期 |
| alarms 周期 | ~25s | ws-client.ts |
SW 保活唤醒 |
| 退避区间 | 1s→30s | ws-client.ts |
重连指数退避 |