gstack Browser-Skills v1 设计解析:把重复的浏览器操作固化成确定性 Playwright 脚本
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
在 gstack(Garry Tan 的 Claude Code 工具集)中,Browser-Skills 是一种"过程层"能力:把 agent 反复执行的浏览器流程(抓取页面数据、操作表单)固化成独立运行的确定性 TypeScript 脚本,通过$B skill run以约 200ms 返回 JSON,替代 agent 每次通过$B原语重新探索页面所耗费的数十秒。本篇基于仓库内的设计文档 BROWSER_SKILLS_V1.md,结合 browse/src 下的实际实现与 browser-skills/hackernews-frontpage 参考技能,完整拆解其架构决策、三层存储模型、spawn 时的作用域令牌信任模型,以及 Phase 2 的/scrape+/skillify自动化固化工具链。
读完本文你可以掌握:Browser-Skill 的目录契约与 frontmatter 字段定义、三层查找(project → global → bundled)的解析逻辑、$B skill list/show/run/test/rm五个子命令的底层实现与输出协议、作用域令牌的签发/吊销生命周期、原子写入的技能固化流程,以及如何为hackernews-frontpage这类参考技能编写 fixture 测试。
一、Browser-Skill 是什么:一个目录就是一套可复现的浏览器流程
按 BROWSER_SKILLS_V1.md 的定义,Browser-Skill 是"per-task 目录",把一个重复的浏览器流程固化成确定性脚本。每个技能的标准目录结构为:
browser-skills/<name>/ ├── SKILL.md # frontmatter + prose contract ├── script.ts # deterministic logic ├── _lib/browse-client.ts # vendored copy of the SDK ├── fixtures/<host>-<date>.html # captured page for tests └── script.test.ts # parser tests against the fixture其中:
- SKILL.md是唯一的事实来源(Single source of truth)——frontmatter 承载
host、triggers、args、version、source、trusted,正文是人类可读的契约说明; - script.ts是确定性逻辑,通过 vendored SDK 调用 daemon;
- _lib/browse-client.ts是 SDK 的逐字节副本,保证技能完全自包含(拷走目录即可运行);
- fixtures/存放抓取的页面快照,供解析器测试离线回放;
- script.test.ts是纯解析器测试,不依赖 daemon。
创建一次之后,后续调用直接运行脚本返回 JSON。设计文档给出的动机是:未来调用约 200ms 返回结果,而 agent 通过$B原语重新探索需要约 30 秒(这是设计文档中的目标描述,而非基准测试数据)。
与 Domain-Skills 的分工:记忆事实 vs 固化过程
仓库中已有 domain-skills 机制(v1.8.0.0),两者共享"按 host、三级作用域"的心智模型,但解决的是不同层次的问题:
| 维度 | Domain-Skills | Browser-Skills |
|---|---|---|
| 本质 | "agent 记住关于某网站的事实" | "agent 把流程固化成确定性脚本" |
| 形态 | 按 hostname 索引的 JSONL 笔记 | per-task 目录 |
| 注入方式 | 会话启动时注入 prompt | 通过$B skill run执行 |
| 隔离机制 | 状态机:quarantine → active → global | spawn 时按次签发的作用域令牌 |
设计文档明确指出:过程层(procedure layer)的生产力收益更大,因为它把抓取和表单操作从"潜在空间"(latent space,即 LLM 的即时推理)推到了可复现的代码里。
二、为什么脚本必须跑在 daemon 之外
设计文档特别解释了这条路径与早期 P1 计划("agent 自写$B命令")的区别:
原 P1 被 Codex 的 T1 意见否决——agent 编写的 TypeScript 无法在 daemon进程内安全执行(ambient globals、构造器小工具、审批与执行之间的 top-level-await TOCTOU 竞态)。正确的方案是"进程外 worker 隔离 + 能力传递 IPC",那是一个"可能永远不会落地"的硬工程。
Browser-Skills 通过把脚本作为独立的 Bun 进程运行在 daemon 之外,绕开了整个问题:
- daemon 从不 import 或 eval 任何技能代码;
- 技能通过 loopback HTTP 与 daemon 通信,使用的与任何外部客户端相同的 wire format。
从源码结构看:spawn 的完整生命周期
核心实现位于 browser-skill-commands.ts 的spawnSkill(),其五步流程与设计文档逐条对应:
generateSpawnId()生成 8 字节随机 hex 的 spawn id;mintSkillToken()签发作用域令牌(TTL = 超时时间 + 30s 缓冲);buildSpawnEnv()按 trusted/untrusted 构造环境变量;- 以
bun run script.ts -- <args>在技能目录下 spawn 子进程,捕获 stdout(上限 1MB)与 stderr,强制超时; finally块中无条件吊销令牌——即使脚本超时或崩溃,令牌也会被回收。
三、三层查找模型:project → global → bundled
存储层实现于 browser-skills.ts,三个层级目录按优先级解析:
| Tier | 路径 | 说明 |
|---|---|---|
| project | <project>/.gstack/browser-skills/<name>/ | 项目级覆盖,优先级最高;需 git 仓库检测项目根 |
| global | ~/.gstack/browser-skills/<name>/ | 用户级,/skillify的默认落盘层级 |
| bundled | <gstack-install>/browser-skills/<name>/ | 随 gstack 安装只读分发,包含hackernews-frontpage |
关键实现细节(均可在 browser-skills.ts 中核对):
没有 INDEX.json,直接遍历目录。listBrowserSkills()(L322-L361)每次调用都重新遍历三层目录并解析每个 SKILL.md 的 frontmatter,50 个技能约 5-10ms。这消除了"索引与磁盘漂移"这一整类 bug。
First-hit-wins 且结果可见。遍历时 project 层优先(seen.has(entry)即跳过),同名技能高优先级层获胜。为消除"first-hit-wins 不透明"的问题(Codex 审查发现 #4),$B skill list输出中每个技能名旁边内联打印解析出的 tier(NAME / TIER / HOST / DESC四列表格),"为什么运行的是那一份?"不再是调试谜团。
项目根与 bundled 根的自动探测。detectProjectRoot()通过git rev-parse --show-toplevel检测项目根(2 秒超时);detectBundledRoot()则判断process.execPath是否匹配/browse/dist/browse$来定位安装目录,源码/开发模式则回退到"从当前文件向上两级"的目录(L110-L124)。
Tombstone 软删除。tombstoneBrowserSkill()把 user 层(project/global)技能移动到该层.tombstones/<name>-<ts>/,$B skill list忽略.tombstones目录。bundled 技能不允许 tombstone(只读),要用全局/项目条目覆盖它。
Frontmatter:唯一的 Schema
parseSkillFile()(L132-L166)用自研的迷你 frontmatter 解析器(支持标量、字符串列表、args的 mapping 列表),必填字段为name(缺省时用目录名兜底)与host;triggers与args可省略,空列表合法;trusted仅当显式写trusted: true才为真(默认 untrusted)。解析失败的技能在listBrowserSkills中被静默跳过(构建期由 skill-validation.test.ts 捕获契约违规)。
参考技能 hackernews-frontpage 的 SKILL.md 展示了完整的 frontmatter 形态:
--- name: hackernews-frontpage description: Scrape the Hacker News front page (titles, points, comment counts). host: news.ycombinator.com trusted: true source: human version: 1.0.0 args: [] triggers: - scrape hacker news frontpage - scrape hn frontpage - get hn top stories - latest hacker news stories ---四、信任模型:spawn 时的作用域令牌,而不是 env-scrub 当沙箱
这是设计文档中"决策 #6",也是整个方案里最容易被误解的部分。信任模型有两条正交的轴:
| 轴 | 机制 | 默认 |
|---|---|---|
| daemon 侧能力 | 每次 spawn 签发绑定read+write作用域的令牌(17 条浏览器驱动命令,减去eval/js/cookies/storage等 admin 命令);clientId 编码技能名 + spawn id;spawn 退出即吊销 | 始终作用域化(绝不使用 daemon root token) |
| 进程侧 env 访问 | trusted: true通过process.env(去掉GSTACK_TOKEN);trusted: false(默认)丢弃除最小允许列表(LANG、LC_ALL、TERM、TZ、锁定 PATH)之外的一切,并显式剥离 secret 模式键 | Untrusted(必须显式 opt in) |
令牌层:skill-token.ts
browse/src/skill-token.ts 包装token-registry,关键行为:
mintSkillToken()(L74-L83):clientId 为skill:<name>:<spawnId>,作用域固定['read', 'write'],tabPolicy: 'shared'(技能可以切换标签页),rateLimit: 0(不限流),过期时间 = spawn 超时 + 30 秒缓冲(TOKEN_TTL_SLACK);revokeSkillToken()幂等——吊销已吊销的令牌返回 false 但不算错误;- 注释中明确解释了为何排除 admin 作用域:"agent 编写的技能不应获得任意 JS 执行";Phase 2 可能为真正需要 eval/js 的人类编写技能提供
admin: truefrontmatter 标志,但会在 skillify 时接受更强的审查。
这一层是真正可执行的边界:技能即使尝试调用eval(admin 作用域)也会被 daemon 返回 403——即便 SDK 把这个方法暴露出来了。能力边界放在了正确的位置。
env 层:卫生(hygiene),不是沙箱
buildSpawnEnv()(browser-skill-commands.ts L416-L455)实现两条路径:
- trusted:透传
process.env,但永远剥离GSTACK_TOKEN(纵深防御,防止父进程的 root token 传播);缺失 PATH 时补最小 PATH; - untrusted:只保留
UNTRUSTED_ALLOWLIST(LANG、LC_ALL、LC_CTYPE、TERM、TZ,L404-L408),PATH 用解析出的 bun 所在目录 + 系统目录拼接,再经SECRET_KEY_PATTERNS二次过滤(L391-L397):
const SECRET_KEY_PATTERNS = [ /TOKEN/i, /KEY/i, /SECRET/i, /PASSWORD/i, /CREDENTIAL/i, /^AWS_/, /^AZURE_/, /^GCP_/, /^GOOGLE_APPLICATION_/, /^ANTHROPIC_/, /^OPENAI_/, /^GITHUB_/, /^GH_/, /^SSH_/, /^GPG_/, /^NPM_TOKEN/, /^PYPI_/, ];最后,GSTACK_PORT与GSTACK_SKILL_TOKEN总是最后注入,父进程无法通过预置同名环境变量来劫持它们。
设计文档对此有诚实的边界声明:Bun 没有内置 FS 沙箱,untrusted 技能仍然可以import 'fs'读取 OS 用户可读的任何文件(如~/.ssh/id_rsa)。env scrub 是卫生措施而非沙箱;真正的 OS 级隔离(macOSsandbox-exec、Linux namespaces/seccomp)是 Phase 4 的工作,且可以干净地插入现有 trusted/untrusted 契约背后。原始计划把 env-scrub 称为"沙箱"被 Codex 批评为"security theater"(安全戏法),修订后的计划如实描述它是"尽力卫生 + 纵深防御,真正的边界在 daemon 侧作用域令牌"。
五、$B skill子命令:实现与输出协议
五个子命令由 browser-skill-commands.ts 的handleSkillCommand()分发:list、show <name>、run <name> [--arg k=v]... [--timeout=Ns]、test <name>、rm <name> [--global]。
输出协议(设计决策 #9,对齐gh/kubectl/docker惯例):
- stdout = 单个 JSON 文档;stderr = 流式日志;退出码 0/非零;
- 默认超时 60 秒(
DEFAULT_TIMEOUT_SECONDS = 60),--timeout=Ns覆盖; - stdout 上限1MB(
MAX_STDOUT_BYTES),超出则截断并以非零退出码上报。
handleRun()的失败语义值得注意(L146-L173):退出码非零、超时、或截断三者任一都会抛出带 stderr 尾部(前 4096 字节)的错误。
一个工程细节:为什么不用管道而用临时文件
runToFiles()(L249-L290)把子进程的 stdout/stderr 指向临时文件而非管道。源码注释记录了完整的调查结论:高负载父进程下,Bun 中第一个piped spawn 会间歇性丢失 stderr($B skill test的bun test恰好把报告拆到两个流:banner 走 stdout、通过/失败摘要走 stderr,丢失 stderr 会把结果悄悄降级成只剩 banner);而Bun.spawnSync虽能可靠捕获,但同步等待会死锁——因为被 spawn 的技能要回调同一个 daemon(通过GSTACK_PORT)。写文件则让内核保证子进程退出前所有字节已落盘,退出后读取必然完整。
六、SDK:browse-client.ts 与"每技能一份副本"的分发模型
规范 SDK 位于 browse/src/browse-client.ts(约 260 行),每个技能在_lib/browse-client.ts携带一份逐字节副本(参考技能的副本见 browser-skills/hackernews-frontpage/_lib/browse-client.ts)。这是设计决策 #4("Option E"):
- 技能完全自包含——把目录拷到任何地方都能运行;
- 版本漂移不可能——SDK 冻结在技能编写时的版本;
- 无 npm 发布流程、无固定路径 tilde import;磁盘成本约 3KB/技能。
SDK 的认证解析在resolveBrowseAuth()(L65-L102),两级回退:
- env:
GSTACK_PORT+GSTACK_SKILL_TOKEN——由$B skill runspawn 时注入,令牌是按次签发的作用域能力; - state file:
BROWSE_STATE_FILE环境变量,或<git-root>/.gstack/browse.json中的port+token(daemon root token)。这条路径只用于开发者直接bun run script.ts的场景——"你的权限,不是 agent 的"。
两者皆无则抛出带明确指引的错误。SDK 只暴露 daemon 现有的POST /commandHTTP 面:goto/wait(导航)、text/html/links/forms/accessibility/attrs/media/data(读取)、click/fill/select/hover/type/press/scroll(交互)、snapshot/screenshot(快照),以及兜底的command(cmd, args)。默认请求超时 30 秒(timeoutMs)。
设计文档特别澄清了它与 cli.ts 中既有 HTTP 客户端的关系:cli.ts的sendCommand()与 CLI 进程强耦合(process.stdout.write、process.exit、server-restart 恢复逻辑),不能作为库复用;browse-client.ts镜像其 wire format 但是 library-shaped——这正是 Codex 审查发现 #3"新 SDK 冗余"被验证为不成立的原因。
七、参考技能深读:hackernews-frontpage
设计决策 #11 选择 HN 首页作为参考技能的理由:无登录、HTML 结构稳定、输出确定、适合 fixture 测试。
解析器实现
browser-skills/hackernews-frontpage/script.ts 的核心是导出的纯函数parseStoriesFromHtml(html): Story[]:
- 主正则匹配每个
tr.athing行,捕获id属性与行体(L57); - 从行体内的
span.titleline > a提取标题与 URL,并做 HTML 实体解码(&、"等); - subtext 边界限定(L72-L82):评论数与分数从下一个
<tr>中取,但必须把搜索范围限定在tr.spacer或下一条tr.athing之前。源码注释说明了这个 bug:不界定的话,招聘帖(无分数)会把下一条故事的分数泄漏进来; - 招聘帖(job postings)没有分数与评论,
points/comments返回null;discuss链接视为 0 评论。
输出协议在文件头注释中明确:stdout 为单个 JSON 文档{ stories: Story[], count },stderr 用于日志,解析/网络失败以非零退出。
无 daemon 的 fixture 测试
browser-skills/hackernews-frontpage/script.test.ts 直接加载 fixtures/hn-2026-04-26.html 并对parseStoriesFromHtml断言:fixture 中 5 条故事、1-based 排名按文档顺序、id 与tr.athing[id]一致、实体解码正确、招聘帖返回 null 字段、discuss计 0 评论、空 HTML 与无 story 行返回[]、缺少titleline的tr.athing行不伪造故事。
SKILL.md 正文点出了 fixture 测试的哲学:"当 HN HTML 改版、我们的选择器失效时,测试会在用户注意到之前对着捕获的 fixture 先失败。这就是重点。"
运行方式:
$B skill test hackernews-frontpage # 即 cd 到技能目录后 bun test script.test.ts八、Phase 2a:/scrape + /skillify 与原子写入
Phase 2a(v1.19.0.0 交付)提供两个技能模板:/scrape <intent>是拉取页面数据的单一入口——新 intent 首次调用通过$B原语做原型并返回 JSON,后续匹配的 intent 路由到已固化的 browser-skill(约 200ms);/skillify把最近一次成功的原型固化为磁盘上的永久技能。变更型流程的姊妹命令/automate推迟到 Phase 2b。
v1.19.0.0 计划评审锁定了四条决策:
| ID | 决策 | 锁定行为 |
|---|---|---|
| D1 | /skillify溯源守卫 | 向前回溯至多 10 个 agent turn 寻找一次清晰边界的/scrape调用(原型 intent 行 + 其尾部 JSON 输出);找不到就拒绝并提示"先运行 /scrape 再说 /skillify",无静默回退 |
| D2 | 合成输入切片 | 模板指示 agent 只提取"产出用户接受 JSON 的最终一轮$B调用" + 用户陈述的 intent 字符串;丢弃失败的选择器尝试、无关聊天、更早会话的内容。关闭了 Codex 发现 #6(选择"从 agent 自身上下文重提示"而非结构化录制器) |
| D3 | 原子写入纪律 | 写入~/.gstack/.tmp/skillify-<spawnId>/,对临时目录运行$B skill test,仅在成功 + 用户批准后 rename 到最终 tier 路径;测试失败或拒绝审批则整体删除临时目录(从未批准的技能不留 tombstone) |
| D4 | 测试范围 | 5 个 gate-tier E2E(scrape 匹配、scrape 原型、skillify 正常路径、溯源拒绝、审批门拒绝)+ 1 个原子写入助手失败清理单测 + 1 个人工验证的冒烟测试(变更型 intent 拒绝) |
D3 的实现:browser-skill-write.ts
browse/src/browser-skill-write.ts 提供三个函数,对应 Codex 发现 #5(原子技能打包与符号链接防御):
stageSkill()(L67-L94):把候选技能文件写入~/.gstack/.tmp/skillify-<spawnId>/<name>/——外层skillify-<spawnId>/包裹目录按 spawn 隔离,并发/skillify互不冲突;files 映射中的相对路径若含/开头或..直接拒绝(纵深防御);commitSkill()(L119-L172):原子 rename 到最终 tier 路径,且——拒绝覆盖已存在的同名技能(审批门必须在调用前暴露命名冲突);lstat检查 staged 目录不是符号链接(拒绝跟随);realpath解析 tier 根并检查目的地未逃逸出 tier 树;discardStaged():删除 staged 目录及空的 wrapper,幂等、尽力而为。
技能名验证(L34-L45)同样严格:/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/——小写字母/数字/连字符,字母开头,无连续或尾部连字符,≤64 字符。
Carry-overs 与 /automate 草图
- 默认 tier 为 global:流程类技能倾向全局,
/skillify时可按项目覆盖(镜像 domain-skill 的 scope); - Bun 运行时分发(Codex 发现 #7)保持 open:Phase 2a 假设 Bun 在 PATH 上(gstack 的 setup 已要求 Bun),写入
/skillify的 SKILL.md "Limits" 节,真正的修复在 Phase 4。
Phase 2b 的/automate是/scrape的变更型姊妹:复用/skillify与 D3 助手,区别在于非固化运行时每个变更步骤都要 UNTRUSTED 包裹的摘要 +AskUserQuestion确认门;固化后脚本可无人值守运行(固化脚本明确枚举了要执行哪些$B click/fill/type调用)。
九、Phase 3 / 4 草图与验证方式
Phase 3(resolver 注入):会话启动时按 host 发现并注入技能,镜像 domain-skill 在server.ts:722-743的注入模式:renderBrowserSkillsForHost(hostname, projectSlug)读三层、过滤host匹配的条目、输出 UNTRUSTED 包裹的块追加到 system prompt。配套gstack-config browser_skillify_prompts开关(默认关):开启后,活动流显示某 host 上 ≥N 条命令且该 host+intent 尚无技能时,/qa、/design-review等任务结束会给出"要不要固化成技能"的提示。
Phase 4:LLM-judge 评估("agent 是否用了技能而不是重新探索?")、fixture 陈旧检测(bundled fixture 与线上页面对比)、untrusted spawn 的 OS 级 FS 沙箱、以及$B skill upgrade <name>(规范 SDK 变更时重新生成 sibling 副本)。
Phase 1 的验证基线(设计文档 Verification 节):bun test通过以下测试文件——browse/test/skill-token.test.ts(15 断言)、browse/test/browse-client.test.ts(26 断言)、browse/test/browser-skills-storage.test.ts(31 断言)、browse/test/browser-skill-commands.test.ts(29 断言)、browser-skills/hackernews-frontpage/script.test.ts(13 断言)、test/skill-validation.test.ts 新增 7 个 bundled 技能契约断言。daemon 运行下的端到端验证:
$B skill list # 显示 hackernews-frontpage (bundled) $B skill show hackernews-frontpage # 打印 SKILL.md $B skill run hackernews-frontpage # 返回 30 条故事的 JSON $B skill test hackernews-frontpage # 运行 script.test.ts十、Codex 审查的八项发现与处置
设计文档完整记录了/codex外部审查的 8 项发现,其处置方式是判断该方案成熟度的最佳窗口:
| # | 发现 | Phase 1 处置 |
|---|---|---|
| 1 | 没有 FS 沙箱时信任模型是假的 | 由决策 #6(作用域令牌)关闭 |
| 2 | Phase 1 对单个 bundled 技能过度设计 | 承认但保留——用户选择完整 Phase 1 以在 Phase 2 落地 agent 编写之前锁定架构;每个子系统小到可以干净移除 |
| 3 | cli.ts:398的既有客户端模式可能使 sibling SDK 冗余 | 验证为不成立——实际 HTTP 客户端是sendCommand(),与 CLI 强耦合,不可作库复用 |
| 4 | "first-hit-wins" 查找不透明 | 由$B skill list/show内联打印 tier 缓解 |
| 5 | 原子技能打包比索引问题更重要;符号链接防御 | Phase 1 关闭(bundled 只读,天然原子);Phase 2 的writeBrowserSkill采用临时目录 + rename +realpath/lstat纪律 |
| 6 | Phase 2 从活动流合成弱(有损环形缓冲) | Open issue:活动流是遥测不是重放 IR,Phase 2 需结构化录制器或从 agent 上下文重写(后者已在 D2 采纳) |
| 7 | Bun 运行时回归:独立 Bun 脚本重新引入 Bun 依赖 | Open issue 留给 Phase 2 分发决策:随技能附带 Bun 二进制 / 编译为自包含可执行文件 / Node.js +cli.tsHTTP 模式 |
| 8 | file://fixture 无法证明时序/认证/导航/懒加载水合 | 文档化为已知限制;Phase 2/automate需要更丰富的 fixture(mock daemon 带时序、HAR 回放等) |
结语:不变的部分同样重要
设计文档专门列出"什么不变",这也是集成安全边界的声明:domain-skills 的存储/状态机/注入完全未动;tunnel-surface 允许列表(server.ts:118-123)仍是同一批 17 条命令;L1-L6 安全栈不受影响——Phase 1 的技能不向 prompt 注入文本,Phase 3 的 resolver 注入也将沿用既有 UNTRUSTED 包裹。
Browser-Skills v1 的整体设计哲学可以概括为:把能力边界放在 daemon 侧(可执行的作用域令牌),把进程侧的 env scrub 诚实地定位为卫生而非沙箱;把技能做成完全自包含的目录(sibling SDK + fixture + 测试);用"每次 spawn 一签一发一吊销"取代长期凭证;用"临时目录 + 测试 + 审批 + 原子 rename"取代直接落盘。这套结构让"agent 把成功流程固化成可复现脚本"成为一条不需要进程内执行未受信代码、且失败时零残留的安全路径。
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考