gstack Browser-Skills v1 设计解析:把重复的浏览器操作固化成确定性 Playwright 脚本
2026/9/7 17:59:19 网站建设 项目流程

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 承载hosttriggersargsversionsourcetrusted,正文是人类可读的契约说明;
  • 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-SkillsBrowser-Skills
本质"agent 记住关于某网站的事实""agent 把流程固化成确定性脚本"
形态按 hostname 索引的 JSONL 笔记per-task 目录
注入方式会话启动时注入 prompt通过$B skill run执行
隔离机制状态机:quarantine → active → globalspawn 时按次签发的作用域令牌

设计文档明确指出:过程层(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(),其五步流程与设计文档逐条对应:

  1. generateSpawnId()生成 8 字节随机 hex 的 spawn id;
  2. mintSkillToken()签发作用域令牌(TTL = 超时时间 + 30s 缓冲);
  3. buildSpawnEnv()按 trusted/untrusted 构造环境变量;
  4. bun run script.ts -- <args>在技能目录下 spawn 子进程,捕获 stdout(上限 1MB)与 stderr,强制超时;
  5. 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输出中每个技能名旁边内联打印解析出的 tierNAME / 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(缺省时用目录名兜底)与hosttriggersargs可省略,空列表合法;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_ALLOWLISTLANGLC_ALLLC_CTYPETERMTZ,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_PORTGSTACK_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()分发:listshow <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 上限1MBMAX_STDOUT_BYTES),超出则截断并以非零退出码上报。

handleRun()的失败语义值得注意(L146-L173):退出码非零、超时、或截断三者任一都会抛出带 stderr 尾部(前 4096 字节)的错误。

一个工程细节:为什么不用管道而用临时文件

runToFiles()(L249-L290)把子进程的 stdout/stderr 指向临时文件而非管道。源码注释记录了完整的调查结论:高负载父进程下,Bun 中第一个piped spawn 会间歇性丢失 stderr($B skill testbun 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),两级回退:

  1. envGSTACK_PORT+GSTACK_SKILL_TOKEN——由$B skill runspawn 时注入,令牌是按次签发的作用域能力;
  2. state fileBROWSE_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.tssendCommand()与 CLI 进程强耦合(process.stdout.writeprocess.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 实体解码(&amp;&quot;等);
  • subtext 边界限定(L72-L82):评论数与分数从下一个<tr>中取,但必须把搜索范围限定在tr.spacer或下一条tr.athing之前。源码注释说明了这个 bug:不界定的话,招聘帖(无分数)会把下一条故事的分数泄漏进来;
  • 招聘帖(job postings)没有分数与评论,points/comments返回nulldiscuss链接视为 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 行返回[]、缺少titlelinetr.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(作用域令牌)关闭
2Phase 1 对单个 bundled 技能过度设计承认但保留——用户选择完整 Phase 1 以在 Phase 2 落地 agent 编写之前锁定架构;每个子系统小到可以干净移除
3cli.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纪律
6Phase 2 从活动流合成弱(有损环形缓冲)Open issue:活动流是遥测不是重放 IR,Phase 2 需结构化录制器或从 agent 上下文重写(后者已在 D2 采纳)
7Bun 运行时回归:独立 Bun 脚本重新引入 Bun 依赖Open issue 留给 Phase 2 分发决策:随技能附带 Bun 二进制 / 编译为自包含可执行文件 / Node.js +cli.tsHTTP 模式
8file://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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询