深入解析 BrowserSkill(Tencent):Agent 借用浏览器的双窗口沙箱与 Rust+MV3 实战
💬 评论腾讯开源的 Agent 浏览器桥:CLI + 守护进程 + MV3 扩展三件套,RefStore/双信号上传/Agent Window/全页截图/Rust 守护进程整套设计深度拆解。

仓库:Tencent/BrowserSkill ↗ License:MIT · Copyright (c) 2026 Tencent v0.3.0 · 2026-09-16 · Cargo + pnpm monorepo,30K 行协议 + Rust CLI + 1700+ 文件 TS 扩展
如果一个 AI Agent 要操作你正在用的浏览器——比如登录态在、个人 Cookie 在、OAuth 还在会话窗口期——它拿不到这台机器,除非你愿意给它一个全新 profile。Playwright/Selenium 每次启动都是一张白纸,登录态、订阅、2FA、信任设备全都得重新走一遍。
Tencent/BrowserSkill ↗ 选了一条完全相反的路:Agent 不重新建浏览器,而是借用你已经在用的那个。它要操作哪个标签,先显式问你借,用完还回来,你的浏览器窗口一动不动。
它和 Pi、God’s Eye View、HyperFrames 都属于”Agent 工具栈基础设施”那一层,但视角不同:Pi 是给 Agent 一个终端,God’s Eye View 是给 Agent 一个地球,HyperFrames 是给 Agent 一个视频合成器;BrowserSkill 是给 Agent 一把你自己的浏览器钥匙。
这篇文章拆掉它。
一、为什么需要 BrowserSkill#
传统自动化测试框架(Playwright/Puppeteer/Selenium)有三条共同的设计前提:
- 隔离 profile:启动一个全新的 Chromium 实例,Cookie、登录态、扩展都从零开始
- 无人值守:不需要考虑用户正在用浏览器
- 可重复:每次测试在相同环境跑出相同结果
这三条对 CI 完美,对 Agent 不完美。Agent 的常见任务里至少有一半是要”借”用户的环境:
- 帮我把昨天看到的那篇博客文章存到 Notion
- 帮我看一下这个工单的状态,如果需要补充信息就回一句
- 帮我点一下这个对话框,我在里面又过期了
- 帮我做一次 PR review,但要先看到真实渲染的页面
这些任务要的是用户的真实 session——你登了某个 SSO、某个账号有 device trust、某个 Cookie 还没过期、某个页面只能从这台机器访问。Playwright 拿不到这些。
BrowserSkill 的解决方案是让 Agent:
- 不新建浏览器,而是连到用户已经打开的那个
- 不操作所有标签,而是默认只在自己的 Agent Window 里工作
- 不偷用户标签,而是显式
tab borrow借一个,用完tab return - 不绕过人工,而是 CAPTCHA/2FA 用
request-help把人类喊过来
定位图就是 README 里那张 mermaid:
flowchart TB
subgraph Harness["Agent Harness"]
Agent["Cursor / Claude Code / Codex / OpenClaw"]
end
subgraph Local["Your Machine"]
CLI["bsk CLI"]
Daemon["bsk daemon"]
Extension["BrowserSkill extension"]
end
subgraph Browser["Browser Profile"]
AgentWindow["Agent Window"]
UserWindows["Your normal browser windows"]
end
Agent -->|"shell: bsk ..."| CLI
CLI -->|"local IPC"| Daemon
Daemon -->|"WebSocket on 127.0.0.1"| Extension
Extension -->|"automates"| AgentWindow
Extension -.->|"borrow tab only when asked"| UserWindowsmermaid一句话总结:它把 Agent → 浏览器这条线从”建一个新浏览器”换成”挂到你已有的浏览器上”。
支持矩阵方面它非常务实:
| 维度 | 范围 |
|---|---|
| OS | macOS Apple Silicon/Intel、Linux x64/ARM64、Windows x64 |
| Browser | Chrome、Edge(其他 Chromium 系扩展可装也能跑;Firefox 在规划) |
| Agent Harness | Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent、DeepSeek Harness |
| 部署模式 | 本地 loopback、远程配对(WSS,内置 pairing link) |
二、架构总览:四个组件、两种 transport#

代码量级很值得列一下(都是从 find + wc 抓的):
| 区域 | 行数 | 文件数 |
|---|---|---|
crates/bsk-protocol | ~4200 | 18 个工具 schema |
crates/bsk-cli | ~11000 | 50+ 个 subcommand |
apps/extension/src/tools | ~12000 | 38 个 handler |
apps/extension/src/browser-driver | ~1700 | CDP wrapper |
apps/extension/src/session-manager | ~1200 | Agent Window + RefStore |
| 顶层 | 506 个 .rs + .ts 文件 | pnpm + Cargo workspace |
2.1 bsk CLI(Rust + clap)#
crates/bsk-cli/src/cli/ 下有 50+ 个子命令,典型动词包括 session / tab / navigate / click / fill / observe / screenshot / upload / download / request-help / evaluate / scroll-to / wheel / record / emulate / doctor。
CLI 的两个关键设计:
- 自动 daemon 发现:
bsk启动时先探$BSK_HOME/run/daemon.sock(Unix)或命名管道(Windows)。如果探不到,且BSK_AUTO_START != 0,自己起一个。沙箱环境(每次 shell 调用清掉子进程)用BSK_AUTO_START=0+ 共用宿主守护进程。 - JSON Lines over UDS:CLI 和 daemon 之间不是 HTTP/JSON-RPC,是裸 JSONL。每条消息一行 JSON,
\n结尾。这样流式长结果(scroll-to 大观测、network 日志、observation cursor 续读)直接读就行,不用考虑 content-length。
2.2 bsk daemon(tokio + tokio-tungstenite)#
crates/bsk-cli/src/cli/daemon.rs 写得很轻,核心是 crates/bsk-cli/src/daemon/ 下的 WS server。
默认行为:
- 监听
127.0.0.1:52800(可用bsk daemon start --port改) - WS 升级时校验
Origin: chrome-extension://…,不是合法 extension origin 直接拒 - 维护
browsers(连上的扩展)和sessions(Agent Window 绑定)两张表 - 同 session 的 RPC 串行(保护 RefStore),不同 session 并行,多浏览器用
--browser <id>
~/.bsk/ 下有 4 个状态文件:
| 文件 | 用途 |
|---|---|
daemon.lock | advisory lock,只允许一个 daemon 实例 |
daemon.json | { sock_path, pid, ws_port, version } |
daemon.log | rolling trace |
daemon.pid | 给 bsk status / doctor 用 |
远程模式(server mode)和本地模式共用同一份代码:bsk daemon start --mode server --listen 0.0.0.0 --tls-cert … --tls-key …,加 pairing/device grant 整套逻辑。我后面单独讲。
2.3 bsk extension(MV3 / WXT / React)#
apps/extension/ 是 MV3 Chromium 扩展,WXT 框架 + React popup + service worker background。代码组织:
| 目录 | 职责 |
|---|---|
transport/ | 可插拔 Transport,目前只 WSTransport |
tools/ | ToolDispatcher → 21 个 handler,每个文件一个 |
session-manager/ | Session、Agent Window、RefStore |
browser-driver/ | CDP 封装(chrome.debugger.*) |
entrypoints/popup/ | 连接状态 UI、Automation settings、长截图 UI |
entrypoints/audit/ | operation audit 面板 |
content/ | Agent Window 内的控制 overlay |
chrome.debugger.attach 用的是 protocol version 1.3(intern 用、Chrome/Edge/Brave 都稳)。Service Worker 可能被休眠,所以 wrapper 把 chrome.runtime.lastError 转成 thrown Error,让上层决定要不要 retry。
2.4 bsk-protocol:18 个工具 + JSON Schema#
crates/bsk-protocol/src/tools/ 下有 18 个文件,每个定义一类工具的参数和返回:
crates/bsk-protocol/src/tools/
├── interaction.rs 497 行 · click / fill / hover / press / select
├── navigation.rs 230 行 · navigate / back / forward / reload
├── observation.rs 469 行 · observe / snapshot / get-html
├── tabs.rs 309 行 · list / borrow / return / focus
├── session.rs 163 行 · start / stop / list
├── file_transfer.rs 213 行 · upload / download
├── human_loop.rs 218 行 · request-help
├── record.rs 747 行 · 录制 v1
├── record_v2.rs 330 行 · 录制 v2
├── record_v3.rs 445 行 · 录制 v3
└── … 还有 scroll / wheel / waits / window / network / console / emulate / dialogsplaintext最大的是 record.rs 系(v1+v2+v3 共 1500+ 行),最小是 record_common.rs 只有 7 行。
每一个都通过 schemars derive JsonSchema,然后 cargo run -p bsk-protocol --bin dump-schema 导出到 apps/extension/src/transport/types.ts——Rust 是 source of truth,TypeScript 是 schema dump 的镜像。apps/extension/src/transport/types.ts 注释明确写了 “kept in sync via tests and schema dumps”。
三、RefStore:会话级 ref 隔离 + 文档代际管理#
代码:
apps/extension/src/session-manager/ref-store.ts
3.1 为什么 ref 系统是核心#
observe 返回的不是整棵 DOM 树,而是一份 “可交互元素的索引 + 引用”:
[1] <button> "Sign in" @e1
[2] <input type="text"> @e2 [required]
[3] <input type="password"> @e3
[4] <a> "Forgot password?" @e4plaintextAgent 拿到这串 ref 之后调 bsk click @e1 --session ab12。如果中途用户导航了,A 标签 @e1 也许已经被新页面的同名按钮顶替了(CDP node id 在新文档里可能被复用),但 Agent 的记忆里它还是”Sign in”——直接点就点错了对象。
RefStore 要解决的就是:让 @eN 在最近一次快照里有意义,导航/较大 DOM 变化后强制重新观察。
3.2 三层抽象#
type BackendNodeId = number; // CDP 节点 id,可能在不同 tab 复用
interface DomRefEntry {
readonly kind: "dom";
name?: string;
backendNodeId: BackendNodeId;
tabId: number | null; // null = 未绑定 tab
frameId?: string; // iframe 用
cdpSessionId?: string; // OOPIF 用
generation: number; // 每次 replace +1
}
interface VisualRefInput {
readonly kind: "visual-region";
readonly candidate: VisualCandidate; // DOM ref + DOM rect + document identity
}
type RefEntry = DomRefEntry | (VisualRefInput & { readonly generation: number });ts一个 ref 系统覆盖 DOM ref 和视觉 ref 两种锚点——视觉 ref 不是截图,是个”DOM 节点 + 几何矩形 + 文档身份”对象,这样后续 click @e3 —image-x 100 —image-y 200 能稳定对应到底层 DOM。
3.3 generation 与 documentRevision#
private generation = 0;
private readonly documents = new Map<number, number>(); // tabId → documentRev
replace(entries: Iterable<readonly [string, RefInput]>): void {
const generation = this.generation + 1;
const next = new Map<string, RefEntry>();
for (const [ref, input] of entries)
next.set(normaliseRef(ref), this.entry(input, generation));
this.map = next;
this.generation = generation;
}
invalidateTab(tabId: number): void {
this.documents.set(tabId, this.documentRevision(tabId) + 1);
let changed = false;
for (const [ref, entry] of this.map) {
const owner = entry.kind === "dom" ? entry.tabId : entry.candidate.document.target.tabId;
if (owner === tabId) {
this.map.delete(ref);
changed = true;
}
}
if (changed) this.generation++;
}ts两个关键点:
replace整张表:任何 observe/snapshot 后调 replace,旧 ref 全部丢——不可能复用上一次快照的 ref 误命中invalidateTab兜底:如果浏览器在没收到 snapshot 的情况下导航了(比如用户在 UI 切到另一个标签),也按 tabId 失效

3.4 effect_state 三态:不要瞎重试#
Upload/download 这种会真实改动外部世界的操作还有一个 effect_state:
none:什么都没发生committed:CDP 报告成功unknown:超时、传输断开、cancel 时被丢
注释明确写了:
Confirmed success wins over a late cancel; an unknown effect is preserved across timeout or transport loss and must not be retried blindly.
这是整个架构里最值得抄的一条规则:和外部世界交互的副作用不能因为网络抖动或 CLI 中断就盲重试。Playwright 经常栽在这里——脚本跑了 30 秒没等到 response,用户 ctrl-c 之后 Playwright 默认重试,结果 form 被提交两次。
3.5 normaliseRef 与跨 tab 校验#
export function normaliseRef(ref: string): string {
return ref.startsWith("@") ? ref.slice(1) : ref;
}
resolve(ref: string, opts: { tabId?: number } = {}): BackendNodeId | null {
const entry = this.map.get(normaliseRef(ref));
if (!entry || entry.kind !== "dom") return null;
if (opts.tabId !== undefined && entry.tabId !== opts.tabId) return null;
return entry.backendNodeId;
}ts@e3 和 e3 视为同一个键;Agent 输入 --tab-id 42 时 store 校验 entry.tabId 不等于 42 就拒绝——ref 不是全局唯一的,ref + tabId 才唯一。
四、文件上传双信号机制#
代码:
apps/extension/src/tools/file-input-transaction.ts(445 行)
文件上传是 Agent 自动化最容易翻车的地方。原因:
<input type="file">在 Chrome 里会弹原生 chooser,Playwright/Puppeteer 通常要在Page.setInterceptFileChooserDialog之后 click,等 chooser 事件触发,再DOM.setFileInputFiles- 但 chooser 事件不一定来——有些网站用 React/Vue 包装成自己点开的 file picker,有些用 contenteditable drag-and-drop,有些 OOPIF 里的 input 根本不会触发顶层 chooser
- 即便都触发了,如果 Agent 端用的是个 setTimeout,可能 trigger click 已经发出去了 chooser 事件还没来,直接归零失败
BrowserSkill 的解法是两条独立信号互相备份:
4.1 信号 A:Chrome chooser 拦截#
const chooserSubscription = options.cdp.onEvent?.((source, method, raw) => {
if (method !== "Page.fileChooserOpened" || !sameTarget(source, target)) return;
const event = raw as ChooserEvent;
chooserEvents.push(event);
});
await waitBounded(
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", { enabled: true }),
deadline, options.signal,
"arming native file chooser interception timed out",
);tsarm 了 interception 之后,后续 trigger click 触发的 native chooser 会被 Chrome 拦截并通过 CDP 事件回传,带 frameId、backendNodeId、mode(selectSingle/selectMultiple)。
4.2 信号 B:frame 作用域 DOM 探针#
const armed = await callOnTrigger<RuntimeReply>(
options, triggerObjectId,
`function(key) {
const doc = this.ownerDocument;
const owner = doc.defaultView;
if (!owner) return false;
const state = { inputs: [], listener: null };
Object.defineProperty(owner, key, { value: state, configurable: true });
state.listener = event => {
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
const candidate = path[0] || event.target;
if (candidate && candidate.nodeType === 1 &&
candidate.localName === "input" && candidate.type === "file") {
if (!state.inputs.includes(candidate)) state.inputs.push(candidate);
event.preventDefault();
}
};
doc.addEventListener("click", state.listener, true);
return true;
}`,
[stateKey], true,
);ts在 trigger node 上跑一段 runtime script,挂一个 capture-phase click listener,把所有 click 路径上的 input[type=file] 收集到 trigger 自己的 window 对象上(用 defineProperty 防止页面覆盖)。然后在 trigger 之后每 20ms 探一次:
const value = reply.result?.value as { count?: unknown; multiple?: unknown };
summary = {
count: typeof value?.count === "number" ? value.count : 0,
multiple: value?.multiple === true,
};ts4.3 双信号合并#
if (chooserEvents.length > 1) {
return transferFailure("unsupported", "file_input_not_activated",
"upload trigger activated more than one file chooser", "none", "resolve_input");
}
const chooser = chooserEvents[0];
let backendNodeId: number | undefined;
let multiple = summary.multiple;
if (chooser) {
if (options.actionTarget.frameId && chooser.frameId !== options.actionTarget.frameId) {
return transferFailure("unsupported", "file_input_not_activated",
"upload trigger activated a file chooser in a different frame", "none", "resolve_input");
}
if (typeof chooser.backendNodeId !== "number") {
return transferFailure("unsupported", "file_input_not_activated",
"upload trigger invoked a non-input file picker", "none", "resolve_input");
}
backendNodeId = chooser.backendNodeId;
multiple = chooser.mode === "selectMultiple";
} else {
if (summary.count !== 1) {
return transferFailure("unsupported", "file_input_not_activated",
summary.count === 0
? "upload trigger did not activate an input[type=file]"
: "upload trigger activated more than one input[type=file]",
"none", "resolve_input");
}
// 用 probe 拿到的 input objectId → DOM.describeNode → backendNodeId
...
}ts合并策略:
- chooser 来了 → 用 chooser 的 frameId 校验是不是同一个 frame,backendNodeId 直接拿,mode 切换决定 multiple
- chooser 没来但 probe 收集到 1 个 input → 用 probe 拿到的 objectId 调 DOM.describeNode 反查 backendNodeId
- 两个都来 → 优先 chooser,但要确保 frameId 匹配,否则报
file_input_not_activated - 都没来 / 来多个 → 报
file_input_not_activated,不退回其它机制

4.4 file-drop-transaction:显式 --mode drop#
file-drop-transaction.ts 是另一条独立路径:
- 临时把 BrowserSkill 自己的 overlay 从 DOM 排除
- 校验 drop zone 仍是激活的 element
- 发 native
dragEnter/dragOver/droptransaction 到那个节点的 CDP target - OOPIF 用 target-local 坐标而不是顶层 click 坐标
4.5 boundary:谁拥有文件路径#
docs/architecture.md 里有一段写得很清楚的契约:
The CLI is the only component that reads an upload source or writes the final download destination. The daemon is the authority for storage capabilities and limits. The extension never receives either agent-facing path.
上传流程是:CLI 拿 agent 给的本地路径 → daemon 给个 opaque session-scoped transfer id,staging chunks → extension 只看 staged path,做完 import。Extension 永远看不到 agent 那边的路径,泄漏面缩到最小。Download 镜像对称:extension 写到 staging,daemon 验 bytes,CLI commit 到最终路径——outbound=false 默认不覆盖,加 --overwrite 才允许。
4.6 effect_state 三态 + 资源清理#
finally {
chooserSubscription?.dispose();
if (probeArmed && triggerObjectId) {
await callOnTrigger(options, triggerObjectId, /* cleanup listener + delete window[key] */, ...);
}
if (interceptionArmed) {
await sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog",
{ enabled: false, cancel: true });
}
await sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup });
if (effect === "unknown" || cleanupFailed) {
await options.cdp.detach?.(target.tabId);
}
}ts三个资源都在 finally 里清理:chooser subscription、probe listener、CDP objectGroup。cleanupFailed + effect_state=unknown 触发 detach——保不准 Chrome 已经在用 staged upload,但我们不知道,所以干脆脱离 debugger,让用户重新连接时再 attach。
五、Agent Window + 借用流:双窗口沙箱#
代码:
apps/extension/src/session-manager/agent-window.ts(70 行)
5.1 为什么必须有独立窗口#
Agent 的核心安全保证是:默认情况下,你自己的浏览器窗口里发生什么跟 Agent 无关。但要让 Agent 真的能做点事,又必须给它一个能跑脚本、能装事件、能 attach debugger 的地方。这两者不冲突:
bsk session start默认会建一个独立的 chrome window- 窗口里只有 about:blank 一个 tab(
AGENT_WINDOW_HOME = "about:blank") - 因为
chrome://页(包括 New Tab Page)会拒绝Page.navigate,必须用 about:blank 起步
export const AGENT_WINDOW_HOME = "about:blank";
async ensureActiveTab(windowId: number, url: string): Promise<number> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
if (first?.id !== undefined) {
if (!first.active) await chrome.tabs.update(first.id, { active: true });
return first.id;
}
const created = await chrome.tabs.create({ windowId, url, active: true });
return created.id!;
}ts注释写得很直白:chrome.windows.create 返回值可能没 id,要 throw 出来而不是吞;chrome.tabs.remove 失败也要在 caller 决定是否要弹错——transactional session-start 不能因为 cleanup 失败就报假的成功。
5.2 借用流程#
bsk tab list --scope user --session ab12
bsk tab borrow <tab-id> --session ab12
... 任务执行 ...
bsk tab return <tab-id> --session ab12
bsk session stop ab12text扩展那边对应的工具是 apps/extension/src/tools/tabs.ts(1211 行)。借用不是把 tab 移到 Agent Window 那么简单,要点是:
- 默认需要用户同意:popup 里有 “Confirm before borrowing tabs” 开关,默认开
- 超时可控:
--timeout 120s只改 wait,不影响要不要同意 - 不能重复:pending / denied / timed_out 都不重试,SKILL.md 明确禁止
- 不能跨后端偷:同一 tab 已经被借出就不能用另一条 bypass 再借

5.3 沙箱规则#
enforceAgentWindow(ctx, target, "screenshot --full-page") ??
enforceCdpAccessibleTarget(target, "screenshot --full-page");ts写工具(navigate / click / fill / upload 等)必须先过两道门:
- tab 在 Agent Window 内(
enforceAgentWindow) - tab 能被 CDP 访问(
enforceCdpAccessibleTarget)
只有显式 borrow 过的用户 tab才能进写工具的 scope。tab borrow 的实现里同样有上面这套校验。
5.4 session 串行#
同一个 session 内的 RPC 全部走守护进程里的 per-session queue 串行执行:
Same session | Daemon serializes RPCs (ref-store safety) Different sessions | Parallel Multiple browsers |
bsk session start --browser <id>when >1 extension connected
为什么同 session 必须串行?因为 RefStore 不是线程安全的,observe 之后 set/clear/get 必须在一个 turn 内完成。如果两个 click 并发,都可能拿到过期的 ref。
不同 session 并行,因为每个 session 自己的 RefStore。
多浏览器场景用 --browser <id> 显式指定要 connect 哪台 extension,本地模式支持一对多(同一台机器装 Chrome 和 Edge,扩展都装了)。
5.5 session stop 是强制的#
session stop 在 agent 工作流里是 must。SKILL.md 写了:
Always run
bsk session stop <id>on success and failure, unless keeping the session open is part of the user’s request. This also returns borrowed tabs. Returned tabs stay open in the user’s window.
5 分钟 idle 超时只是兜底,不能依赖。stop 之前所有借用的 tab 会被自动归还。
六、全页截图:流式 tile + 中断响应#
代码:
apps/extension/src/tools/screenshot-full-page.ts+apps/extension/src/long-screenshot/
6.1 工具栏里的”全页截图”#
bsk screenshot --full-page --out page.png 是 v0.2 才加的功能,核心思路和 HyperFrames 完全相反:
| 维度 | HyperFrames | BrowserSkill |
|---|---|---|
| 时间模型 | 确定性 frame clock,time=floor(frame)/fps | 没有时间概念,只截静态帧 |
| 渲染方式 | seekFrame(n) + HeadlessExperimental.beginFrame 原子截帧 | scrollTo(y) + Page.captureScreenshot |
| 视频合成 | ✅ <video> 预提取帧图 | ❌ 不支持 |
| 实现复杂度 | 大量时钟同步代码 | 普通网页几行循环搞定 |
| 适用 | 动画 / 视频 / 确定性渲染 | 普通网页 / 长 feed / 表单截图 |
6.2 主循环#
const client = createPageClient(target.tabId, id, controller.signal, checkTab);
const checkTab = async () => {
controller.signal.throwIfAborted();
const current = await deps.tabsApi.get(target.tabId);
if (
manager.get(ctx.sessionId) !== ctx ||
!isAgentControlledTab(ctx, target.tabId) ||
!current.active ||
current.windowId !== target.windowId ||
current.url !== target.url
)
throw new ScreenshotError("changed");
controller.signal.throwIfAborted();
};
const changed = () => controller.abort(new ScreenshotError("changed"));
const navigated = (info: { tabId: number; frameId: number }) => {
if (info.tabId === target.tabId && info.frameId === 0)
controller.abort(new ScreenshotError("interrupted", "navigation"));
};
const removed = (tabId: number) => {
if (tabId === target.tabId) changed();
};
const activated = (info: chrome.tabs.TabActiveInfo) => {
if (info.windowId === target.windowId && info.tabId !== target.tabId) changed();
};ts每一帧截图前都跑 checkTab:session 还有效、tab 还是 agent 控制、active、url 没变。任意一项变了就 throw。
armDeadline 把超时平铺成一个递归 setTimeout(用 Math.min(remaining, 0x7fffffff) 避免 setTimeout 上限),保证大页面 2 分钟里所有 tile 都有 cap。
6.3 三种 scope#
bsk screenshot --full-page --scope follow --out page.png # 默认,跟高度增长
bsk screenshot --full-page --scope current --out page.png # 锁首次高度shfollow 适配无限滚动/懒加载 feed,current 适合”我就要看当前这段”,比如确认首页文章列表渲染完了。SKILL.md 明确说:
Use
--scope currentwhen capturing the currently loaded range is requested: it stops at the initial document height, even if a loading indicator remains. Later content below that boundary is excluded; report this range rather than claiming all feed entries were loaded.
loading_stalled 是 current 模式下 height 30 秒不增长就报错的特殊类型——不是简单超时,而是”它真的没在加东西”。
6.4 中断响应#
5 个中断源全部走 AbortController:
signal?.aborted— 父级 AbortSignal(CLI ctrl-c)controller.signal.throwIfAborted()— 自检navigated— Page 帧导航removed/activated— tab 被关或用户切走armDeadline— 超时
失败时 failed full-page captures save no partial image——不留半张图,这和 HyperFrames 的 __renderReady 门闩精神一致:不要把半成品当成成功。
6.5 截图输出路径#
screenshot 路径返回的是 local PNG path(临时或 --out),返回路径是绝对路径,不是 stub:
type FileLikeProgressDialog = { opened: true, file: string } | { opened: false };tsCLI 拿到 path 自己处理。--ref @e3 --out element.png --json 多给 dimensions + bytes;--ref 和 --full-page 不能合用(语义冲突)。
Canvas 元素有个特殊处理:observe 返回 canvas [visual:screenshot] 时给的是 text,不是像素——文本模型永远从截图拿真画面,不会从标签名瞎猜。后续 click 也走 bsk click @e3 --capture <id> --image-x <x> --image-y <y>,坐标用原始 PNG 的,不是浏览器 viewport 的——这个细节很重要,resize 过的图用 viewport 坐标点会偏。

七、远程模式:让 Agent 跑在服务器上#
代码:
crates/bsk-cli/src/cli/daemon.rs+docs/remote-extension-connection.md
v0.3.0(2026-09-16)最重磅的新功能是远程模式:Agent 跑在服务器上,扩展跑在你工作的电脑上,中间走 authenticated WSS。
7.1 角色分离#
+----------------+ WSS (authenticated) +----------------+
| Remote Agent | <-------------------------------> | Extension |
| + CLI + Daemon| | (Chrome) |
+----------------+ +----------------+
↑ ↑
| UDS / JSONL | 127.0.0.1:52800
↓ ↓
+----------------+ +----------------+
| bsk daemon | | bsk extension |
| (server mode) | | |
+----------------+ +----------------+plaintext关键点:扩展发起出站连接,用户机器不需要开任何入站端口。Agent 在云上,浏览器在本机,反向穿隧。
7.2 一句话启动#
bsk daemon start --mode server \
--listen 0.0.0.0 --port 52800 \
--public-url wss://browser.example.com:52800/extension \
--tls-cert /etc/bsk/fullchain.pem \
--tls-key /etc/bsk/privkey.pemshTLS 必须是合法的,hostname 必须等于 public URL。Server mode 永远前台运行,不因 idle 退出——supervisor 管理生命周期。证书改了必须重启。
7.3 Pairing 链路#
# 服务器端
BSK_AUTO_START=0 bsk daemon pair
# → 输出一次性 pairing link,例如 wss://browser.example.com/extension?pair=xxx
# 用户在扩展 popup 里选 "Remote connection",贴 link
# → 扩展拿 credential、存 IndexedDB、向服务器发 authorize
# → 服务器建 device grant(默认 90 天)、下发 device id
# 之后扩展定期 renew(默认 30 天),server 检查仍在 grace 内sh服务器端状态:
| 资源 | 用途 |
|---|---|
BSK_HOME | 私有的 device grant + IPC metadata;不能跨用户共享 |
daemon.json | ws_port + pid + version |
| device grants | credential hash(不是 credential 本身) |
| pairing tokens | 一次性,默认 5m TTL,最长 1h |
7.4 容量限制#
| 限制项 | 默认值 | 含义 |
|---|---|---|
--max-connections | 64 | 在线浏览器上限(1-1000);满后新连接 HTTP 503 + Retry-After |
--authorize-rate-limit | 60 | 每个 peer IP 每分钟 authorize 请求上限(1-60000) |
--device-ttl | 90d | device 生命周期,最长 366d |
--renew-after | 30d | extension 该续期的时间;必须 < device-ttl |
--pairing-ttl | 5m | pairing link 寿命,最长 1h |
| TCP/TLS/HTTP setup 并发 | 64 | 超出直接 drop,rate-limited warn |
| peer counter 池 | 1024 | 满了就 bounded overflow,不驱逐已有 |
| global budget | 16 × per-peer | authorize 总请求上限 |
rate limit 写到配置文件里读 atomic snapshot,不持锁。写 contention 超过 500ms 直接 503,不 invalidate 合法连接。
7.5 撤销#
bsk daemon revoke DEVICE_ID
bsk daemon revoke --allsh撤销会立即关掉现连接。已经执行的浏览器动作不能撤销——这和上传的 effect_state 哲学一致:副作用一旦发生,撤销是 log 层的事,不是 state 层。
devices 命令列出所有 device + browser + label + expiration,永远不显示 credential 本身。
7.6 反向代理模式#
不想内置 TLS?可以走传统 reverse proxy:
bsk daemon start --mode server --listen 127.0.0.1 --port 52800 \
--public-url wss://browser.example.com/extensionshproxy 转发 /extension 和 /extension/authorize,保留 Origin / Authorization / Sec-WebSocket-Protocol 头,永远不要 log Authorization / subprotocol / request body。认证由 bsk 内置 server 负责,proxy 注入的用户头不绕过。
proxy 后面的 client IP 都是 proxy 自己的 IP,per-peer rate limit 是共享的——所以要在 proxy 上配 per-client rate limit。
7.7 远程模式的边界#
docs/architecture.md 里写得很明确:
Upload and download are supported only for local connections. Remote sessions return
unsupported; screenshots and other RPC content results remain available.
远程模式不支持文件传输(因为 staging 路径是 daemon host 的,extension 看不到)。截图、观察、scroll-to 这些纯 RPC 输出照常。
还有一个细节:pairing link 不能 query string 里塞 credential(proxy log 会抓到),设计成单独 /extension/authorize 路径走 Authorization 头。
八、SKILL.md:用 SKILL 直接告诉模型”怎么做”#
代码:
skill/SKILL.md(15KB)
8.1 skill 不是一个文件,是协议#
BrowserSkill 的 SKILL.md 用了和 agentskills.io/specification ↗ 兼容的 frontmatter:
---
name: browser-skill
description: |
Use when the user asks to automate their logged-in Chromium browser: visit
and read pages, fill forms, scrape data, click through flows, regression-test
a PR's UI, validate a deployed page, or operate a tab they identify. Requires
the bsk CLI and browser extension.
---yamldescription 是给 harness 用的——Claude Code / Cursor / Codex / Pi 看到 frontmatter 的 description 就知道”哦这个 skill 适用”,会主动加载 SKILL.md 正文。Pi Agent Harness 那边也是这个套路。
8.2 内容不是 cheat sheet,是工作流#
1. bsk session start --json
2. bsk navigate https://example.com --session <id>
3. bsk observe --session <id> # 拿 @eN
4. bsk click @e3 --session <id>
5. bsk session stop <id> # 必须text但写得远比步骤详细,关键约束都白纸黑字写在 SKILL.md 里:
session stop是 mandatory——永远要在 success / failure 时调用- 不要重复 pending / denied / timed_out 的 borrow——不绕过另一条后端
- 同一页面 nav 之后 ref 全失效——必须重新 observe
observe --max-tokens配合--cursor续读——但续读时不要叠 hover probe / depth 改动fill_value_mismatch不要盲 refill——先 read 字段,可能格式化已经满足要求page_hidden≠user_cancelled——前者是环境中断,后者是用户主动 cancelrequest-help不能反向强制——BSK_REQUEST_HELP=off和--unattended都是 deprecated,不绕过 Automation settings
8.3 “Help disabled” 怎么办#
如果用户把 “Allow requests for human help” 关了,SKILL.md 写了一段非常清晰的降级策略:
Help disabled: do not request help or re-enable it. Use existing login state, authorized inputs and viable alternatives; disabling help adds no permission and does not remove borrow confirmation or host restrictions. Where authorized, a vision-capable model may attempt graphical verification. Phone-only QR scans, face verification, missing SMS codes or image-only tasks for a text-only model may remain blocked. Report a specific blocker only when inputs/capabilities are missing or viable approaches are exhausted; continue independent work.
Do not loop on identical failures, repeat unknown effects or switch backends to bypass limits. On an unrecoverable failure, report the blocker and stop the owned session.
这是真正工程师写的 skill:不教模型”怎么 hack 关掉的人类保护”,而是教模型”如果帮不了,就用现有材料继续工作,直到真的走不通为止”。
8.4 复盘值得抄的几条#
最后列一下这个 SKILL.md 里特别值得抄到自家 skill 的几条:
- 所有 session-scoped 命令都要
--session <id>——ref 没 session id 就没意义 session stop写进”必须”——不是”建议”,是”必须”- 不要重复 borrow/try 失败结果——失败就是失败,不要用另一条路绕
- 每次 nav 之后都 observe 再新动作——ref 永远按”最新一次快照”理解
- Human-in-loop 是最后一道——不是每次操作都要询问
- effect_state=unknown 时不要盲重试——
Ctrl-C之后你已经不知道副作用有没有发生
九、复盘:它真正值得抄的设计#
写完 8 节,简单提炼一下 BrowserSkill 里真正值得抄到自己项目的设计:
9.1 一个 ref 系统覆盖两种锚点#
DOM ref 和视觉 ref 共用同一张 Map<normaliseRef, RefEntry>,Entry 是 union 类型,后续 click / hover / screenshot / capture 都查同一张表。Agent 不用记”这个按钮是 @e3 还是 v3”,反正都能点。
9.2 generation + documentRevision 两层失效#
第二个 ref (generation)给整批重置用,第一个 ref (documents[tabId])给局部失效用。一个 replace 调用 bump generation 一次,invalidateTab 自己管理自己的 documentRev。两层不冲突,而且一个 invalidateTab 可以 bump 0 次 generation(没有命中)、1 次或多次。
9.3 effect_state 三态契约#
none / committed / unknown 这个三分比 success/failure 二分好用得多。它显式承认”我不知道有没有生效”,让上层(用户/CLI)决定怎么处理 unknown,而不是上层只能相信 success 之后又突然面对 conflict。
这条规则几乎所有需要和现实世界打交道的系统都用得上——提交订单、发邮件、扣库存、转账……
9.4 双信号互相备份#
上传 chooser 事件 + frame 作用域 DOM 探针,两者独立,任一成功都能解析 input。失败时给精确 reason(file_input_not_activated / file_input_not_activated / set_file_input_failed),不”模糊归零”。
9.5 Agent Window + 借用表#
默认沙箱,显式借还。这和 AppArmor / SELinux 默认 deny + 显式 grant 是一个套路。把”安全模型”做成”用户能直接看到的状态”(popup 上的开关),而不是隐藏在 CLI flag 里。
9.6 schema dump 单向同步#
Rust 是 source of truth,TypeScript 是 schema dump。cargo run -p bsk-protocol --bin dump-schema 生成 TS 类型,tests 保证同步。这比维护两套手写类型可靠得多,也比 pnpm 上 protobuf / openapi 链路短得多。
9.7 远程模式保留本地 IPC#
Server mode 和 local mode 用同一份 daemon 代码,只多 pair / device grant 逻辑。CLI/daemon 之间仍然是 local IPC(/run/daemon.sock),不会因为远程就引入跨机 RPC。远程只发生在 daemon ↔ extension 这一段。
9.8 SKILL.md 把”不能做什么”写得比”能做什么”还多#
Never invent tab IDs or keep a user tab across unrelated work. Do not repeat pending, denied or timed-out borrows. Never retry or switch upload modes for
effect_state=unknownorcommitted. Do not loop on identical failures, repeat unknown effects or switch backends to bypass limits.
这种”显式禁止”比”鼓励尝试”难写得多,也管用得多。
一些统计#
- 仓库大小:3.1MB tarball / 506 个
.rs/.ts文件 - 协议代码:
crates/bsk-protocol共 ~4200 行,18 个工具 schema - CLI 代码:
crates/bsk-cli共 ~11000 行,50+ 个 subcommand - 扩展代码:
apps/extension共 ~12000 行工具 + 1700 行 CDP wrapper - SKILL.md:15KB 工作流指令,明确”必须 / 禁止”
- License:MIT
- 协议版本:browser <-> daemon 1.3(cli / extension / dsh plugin 都共享)
- 支持 8 大 harness:Claude Code / Cursor / Codex / OpenClaw / CodeBuddy / WorkBuddy / Pi / Hermes Agent
参考#
- Tencent/BrowserSkill 仓库 ↗
docs/architecture.md↗docs/remote-extension-connection.md↗docs/long-screenshot.md↗docs/sandboxed-agents.md↗apps/extension/src/session-manager/ref-store.ts↗apps/extension/src/tools/file-input-transaction.ts↗skill/SKILL.md↗- agentskills.io specification ↗
- Chrome DevTools Protocol ↗