首页
/ gstack Domain Skills 深度解析:Agent 自写的站点笔记如何持久化、晋升与防注入

gstack Domain Skills 深度解析:Agent 自写的站点笔记如何持久化、晋升与防注入

2026-09-06 17:41:53作者:明树来

Domain Skills 是 gstack 中浏览器 Agent 的“跨会话站点记忆”机制:Agent 在某个网站上总结出非显而易见的操作技巧后,会把笔记保存为该站点的 domain skill,后续同一站点的新会话会自动把笔记注入 prompt 上下文。本文基于 docs/domain-skills.mdbrowse/src/domain-skills.tsbrowse/src/domain-skill-commands.ts 等源码实现,完整讲解其 CLI 用法、三态状态机、JSONL 存储纪律、五层安全模型与错误排查,读完你可以理解这套“Agent 给 Agent 写笔记”机制的全貌与防 prompt injection 设计。

一、Domain Skills 是什么:Agent 给未来的自己留的便签

gstack 的浏览器 Agent(browse 模块)在执行网页任务时会遇到大量站点特有的“隐性知识”,例如某个按钮藏在 iframe 里、某页面需要先展开折叠面板。Domain Skills 就是让 Agent 把这些经验写下来、跨会话复用的机制,官方描述为:

Per-site notes the agent writes for itself. Compounds across sessions: once an agent figures out something non-obvious about a website, it saves a skill, and future sessions on that host get the note injected into their prompt context.

这个模式借鉴自 browser-use/browser-harness 项目的 per-site-notes 思路,但 gstack 只拷贝了“按站点记笔记”的模式,不拷贝自修改运行时(self-modifying-runtime)的模式。文档明确强调:skill 是加载进 prompt 的 markdown 纯文本,不是可执行代码——这决定了它的安全边界:skill 的内容本质上是 agent 撰写的、会进入未来 prompt 上下文的不可信内容,因此整套机制的重头戏在于防注入与晋升管控(详见第五节)。

二、CLI 实战:$B domain-skill 子命令全集

gstack 文档中 $B 指 browse 守护进程的 CLI 入口,domain-skill 是其下的元命令(在 browse/src/commands.ts 中注册于 Meta 分类)。完整的子命令面如下,源码实现在 browse/src/domain-skill-commands.ts

# Agent 任务成功后写下学到的站点知识。
# 注意:host 自动取自当前激活标签页(不需要 agent 传参数)
echo "# LinkedIn Apply Button

The Apply button on /jobs/view pages is inside an iframe with a class
matching 'jobs-apply-button-iframe'. Use \$B frame --url 'apply' first,
then snapshot." | $B domain-skill save

# 查看已保存的 skill
$B domain-skill list

# 读取某个 host 的 skill 正文
$B domain-skill show linkedin.com

# 用 $EDITOR 交互式编辑
$B domain-skill edit linkedin.com

# 把当前项目的 skill 提升为全局(跨项目生效)
$B domain-skill promote-to-global linkedin.com

# 回滚最近一次编辑
$B domain-skill rollback linkedin.com

# 删除(tombstone,可通过 rollback 恢复)
$B domain-skill rm linkedin.com

各子命令的关键行为,均可在 browse/src/domain-skill-commands.ts 中对应到具体 handler:

子命令 行为 源码要点
save 正文从 stdin 或 --from-file <path> 读取,绝不接受内联 argv(多行 markdown 会被 shell 引号搞坏);host 从激活标签页顶层 origin 派生 readBodyFromArgs + deriveHostFromActiveTab;空正文直接报 Save failed: empty body
list 分组展示当前项目可见的 project 与 global skill,含状态、版本、字节数、使用次数与标记数 listSkills 只返回非 tombstone 行
show <host> 打印 skill 正文,头部附 host、来源 scope、版本、使用次数与 flag 数 只返回 active(项目)或 global 状态的 skill,quarantined 的不显示
edit <host> 把当前正文写入临时文件,拉起 $EDITOR(缺省 vi)编辑,保存后以 source: 'human' 重新落盘 内容与原文相同则输出 No changes,不产生新版本
promote-to-global <host> 仅允许把 active 状态的项目级 skill 提升为 global;skill 在全局文件中从 v1 重新计数 非 active 状态直接抛错
rollback <host> [--global] 恢复上一个版本,版本号单调递增(回滚内容本身成为新版本) 少于 2 个版本时拒绝
rm <host> [--global] 追加 tombstone 行,不物理删除 输出提示可用 rollback 恢复

一个值得注意的设计:save 命令输出的 Saved 结果会明确告知用户“skill 目前是 quarantined,未连续使用 3 次(且不被分类器标记)前不会在 prompt 中生效”,即保存成功不等于立即生效。

主机名归一化规则

save 不接受 agent 提供的 host 参数,而是调用 browse/src/domain-skills.ts 中的 deriveHostFromActiveTab 从当前页面 URL 派生。派生前先经 normalizeHost 归一化,规则为:

  • 转小写、去首尾空白;
  • 剥掉协议前缀(https?://)、路径、query、hash;
  • 剥掉端口号;
  • 剥掉 www. 前缀,但保留完整子域名(按子域精确匹配);
  • Punycode 域名按编码后原样存储。

三、状态机:quarantined → active → global

这是整个机制的核心,防止被污染的页面笔记直接“洗”进长期记忆。文档给出的状态机如下:

  ┌──────────────┐  3 successful uses        ┌────────┐  promote-to-global   ┌────────┐
  │ quarantined  │ ─────────────────────▶  │ active │ ──────────────────▶  │ global │
  │ (per-project)│  (no classifier flags)  │(project)│  (manual command)    │        │
  └──────────────┘                          └────────┘                      └────────┘
         ▲                                       │
         │  classifier flag during use           │  rollback (version log)
         └───────────────────────────────────────┘

源码中对应 SkillState = 'quarantined' | 'active' | 'global',晋升阈值常量 PROMOTE_THRESHOLD = 3(见 browse/src/domain-skills.ts)。三个关键语义:

  1. 新保存的 skill 一律是 quarantined,且绝不自动注入 promptreadSkill 的实现明确只返回 state === 'active'(项目层)或 state === 'global'(全局层)的行,quarantined 行对读取路径完全不可见。

  2. 自动晋升需要同时满足四个条件,由 recordSkillUse 维护:

    • use_count 累计达到 3;
    • flag_count === 0(使用期间从未被分类器标记);
    • 当前状态仍是 quarantined
    • classifier_score > 0(正文确实被 L4 ML 扫过)。

    关于最后一条门槛,源码注释交代了一个重要现状:save 路径写入的 classifier_score 固定为 0(含义是“从未被 ML 扫描过”),而加载期的 L4 扫描路径已随旧 chat 路径一起移除,因此生产路径写入的 skill 目前无法通过自动晋升——这个 classifier_score > 0 门槛正是补偿性防线:一旦 L4 重新接线并写入非零分数,自动晋升即自动恢复。换言之,当前版本中 quarantined skill 想进入 prompt 需要等待 L4 重新接入,或被手动处理;这与文档描述的“设计意图”存在时差,从源码注释看属于有意保留的开关。

  3. 跨项目晋升(global)必须显式人工执行 $B domain-skill promote-to-global <host>,永不自动。文档给出的理由(源自 Codex T4 outside-voice 评审)是:无差别的跨项目记忆复利会在不相关项目之间泄漏上下文。

读取优先级上,readSkill 先查项目层、再 fallback 全局层:项目级 active skill 会遮蔽(shadow)同 host 的 global skill。这让你可以在某个项目里为同一网站写一份定制版笔记,而全局笔记继续服务其他项目。

四、存储层:双 JSONL 文件与追加写纪律

Skill 存储在两个位置:

  • 项目级~/.gstack/projects/<slug>/learnings.jsonl —— 与 /learn skill 共用同一个 JSONL 文件,domain skill 是其中 type:"domain" 的行(同一文件里还有学习记录等其他行,解析器按 type === 'domain' 过滤);
  • 全局级~/.gstack/global-domain-skills.jsonl —— 只存放 state:"global" 的行。

两个文件都是追加写(append-only)JSONL。每行是一个完整的 DomainSkillRow,字段结构在 browse/src/domain-skills.ts 中定义:

export interface DomainSkillRow {
  type: 'domain';
  host: string;            // 归一化后的 host
  scope: 'project' | 'global';
  state: 'quarantined' | 'active' | 'global';
  body: string;             // markdown 正文
  version: number;          // 版本计数器,单调递增
  classifier_score: number; // 0 表示“从未被 L4 扫描”
  source: 'agent' | 'human';
  sha256: string;           // 正文摘要
  use_count: number;
  flag_count: number;
  created_ts: string;
  updated_ts: string;
  tombstone?: boolean;      // 删除标记
}

docs/domain-skills.md 描述的三条存储纪律,在源码中逐条可验证:

  1. 原子追加appendRowO_WRONLY | O_CREAT | O_APPEND 打开文件后单次 writeSyncfsyncSync。源码注释说明依据:POSIX 对小于 PIPE_BUF(通常 4KB)的 O_APPEND 写保证原子性,而单行 JSON 远小于该上限,因此即使并发写入也不会交错。
  2. 容忍式解析readRows 逐行 JSON.parse,任何一行(尤其是崩溃时刻写了一半的尾行)解析失败即静默跳过,保证“写一半崩溃”不会毒化后续所有读取;TODOS.md 中亦记录了该 JSONL 存储的后续演进方向(多写者场景下考虑 SQLite)。
  3. 墓碑 + 压缩rm 不删行,而是追加一行 tombstone: true 的新版本行;“latest-wins”解析时墓碑胜出,使被删的 skill 保持已删状态;空闲 compactor 周期性重写文件做物理清理。

版本解析逻辑(resolveLatest)按 (scope, host) 分组、取版本号最大的行,版本号在每次 save/use/rollback/tombstone 时单调 +1,因此“回滚”实际是把上一个版本的内容以新版本号重新落盘——历史完整保留,审计链不断。

五、安全模型:Agent 写的内容为何能进未来 prompt

文档对此的定性非常直接:“Skills are agent-authored content loaded into future prompt context. That makes them a classic agent-to-agent prompt-injection vector.”(这是典型的 agent 间 prompt 注入向量。)防御分五层,原文表格如下:

Layer 内容 位置
L1-L3 数据水印、隐藏元素剥离、ARIA 正则、URL 黑名单 content-security.ts(编译进二进制)
L4 TestSavantAI ONNX 分类器 security-classifier.ts(sidebar-agent,非编译)
L4b Claude Haiku transcript 分类器 security-classifier.ts(sidebar-agent)
L5 金丝雀令牌泄漏检测 security.ts

L1-L3 在 save 时运行(daemon 侧),L4 ML 分类器在 load 时运行,即每个把 skill 载入 prompt 的会话都会重新校验内容——这样才能抓住只在分类器模型更新后才显现的问题。下面结合源码展开各层。

L1-L3:save 时执行的内容过滤器

save 命令在落盘前调用 browse/src/content-security.tsrunContentFilters(body, page.url(), 'domain-skill-save'),被拦截则抛出 Save blocked: ... 并记录 domain_skill_save_blocked 遥测。该模块的四个防御层:

  1. L1 数据水印(Datamarking)datamarkContent 在每第 3 个句尾句号后插入由零宽空格包裹的会话级随机标记(randomBytes(3) 取 base64 前 4 位),用于检测内容外泄——若这些不可见水印出现在别处,说明页面文本被原样搬运出去了。
  2. L2 隐藏元素剥离markHiddenElements 在页面内检测 opacity < 0.1、字号 < 1px、屏幕外定位、前后景色相同、clip 隐藏、visibility:hidden 等“人看不见、机器看得见”的元素,打上 data-gstack-hidden 标记后从提取文本中剔除;同时用一组注入特征正则(如 ignore (previous|above|all) instructionsyou are (now|a)system:do not follow/obeynew instructions: 等)扫描 ARIA label,命中即标记。
  3. L3 URL 黑名单:内置一份 10 个数据外泄汇点域名的清单(requestbin.compipedream.comwebhook.sitehookbin.comrequestcatcher.comburpcollaborator.netinteract.shcanarytokens.comngrok.iongrok-free.app),对页面 URL 和正文中出现的所有 http(s):// URL 做大小写不敏感的子串匹配(源码注释指出这是为了防 https://WEBHOOK.SITE/x 这类大写绕过)。

过滤器受环境变量 BROWSE_CONTENT_FILTER 控制:off / warn(默认)/ block。此外还有一个信封逃逸防护:wrapUntrustedPageContent 会把内容中伪造的 ═══ BEGIN/END UNTRUSTED WEB CONTENT ═══ 哨兵串中间插入零宽空格使其失效,防止恶意页面提前闭合“不可信内容”边界、伪造出 LLM 眼中的“可信段”。

L4:TestSavantAI ONNX 注入分类器

docs/domain-skills.md 提到的 L4 在 browse/src/security-classifier.ts 中实现,几个可验证的实现细节:

  • 模型是 TestSavantAI 的 BERT-small ONNX(约 112MB),首次使用时从 HuggingFace 下载到 ~/.gstack/models/testsavant-small/,按 @huggingface/transformers 期望的 onnx/model.onnx 布局暂存;
  • 运行位置有讲究:该模块只能被 security sidecar 子进程导入,不能进入编译后的 browse 二进制——因为 onnxruntime-node 这个原生模块无法从 Bun 编译二进制的临时解包目录 dlopen
  • fail-open 降级:模型加载失败或推理异常时返回 degraded 状态、置信度 0,上层判定组合器回落到“仅 L1-L3”的结果,只有这一层额外防线消失,整体服务不中断;提供 GSTACK_SECURITY_OFF=1 环境开关可强制关闭 ML 分类器;
  • 输入先经 htmlToPlainText 剥掉 HTML 标签(分类器是按纯文本训练的,标签噪声会稀释注入信号),再做 4000 字符上限与 512 token 截断(对应 BERT-small 的 max_position_embeddings)。

而 skill 存储层对分数的硬门槛写在 writeSkill 中:classifier_score >= 0.85 直接抛错 Save blocked: classifier flagged content as potential injection,这正是文档错误表中“L4 score ≥ 0.85 at save”一行的来源(见 browse/src/domain-skills.ts 第 252-259 行的实现)。

防“confused deputy”:host 永不接受 agent 参数

文档特别强调的一条设计:save 从激活标签页的顶层 origin 推导 host,而不是接受 agent 传参。这关闭了 Codex 评审标记的一个 confused-deputy 漏洞——否则恶意页面的重定向链可以诱骗 agent 把投毒内容写到另一个域名名下。deriveHostFromActiveTab 还会拒绝 about:blankchrome:// 页面,抛出文档错误表中对应的 Cannot save domain-skill: no top-level URL on active tab

六、错误参考表

docs/domain-skills.md 的 Error reference 完整继承如下(每条错误信息在 browse/src/domain-skill-commands.tsbrowse/src/domain-skills.ts 中均有逐字对应的 throw 语句,格式统一为“现象 + Cause + Action”三段式):

Error Cause Action
Save blocked: classifier flagged content as potential injection save 时 L4 分数 ≥ 0.85 改写 skill 去掉指令式语句后重试
Save blocked: <L1-L3 message> URL 黑名单命中或 ARIA 注入 检查 skill 正文中的可疑模式
Save failed: empty body stdin 与 --from-file 都没内容 用管道把 markdown 喂给 $B domain-skill save,或传 --from-file <path>
Cannot save domain-skill: no top-level URL on active tab 标签页是 about:blankchrome://... $B goto <target-site> 再 save
Cannot promote: skill is in state "quarantined" 尚未自动晋升 在本项目继续使用该站点,直到 3 次成功使用且无分类器标记
Cannot rollback: <host> has fewer than 2 versions 只有 1 个版本 改用 $B domain-skill rm 删除

七、遥测:只记 host,不记内容

当遥测开启时(默认 community 模式,除非显式关闭),domain-skill 相关事件写入 ~/.gstack/analytics/browse-telemetry.jsonl(事件清单同时记录在 browse/src/telemetry.ts 头注释中):

  • domain_skill_saved {host, scope, state, bytes} —— save 成功后记录(handleSavelogTelemetry 调用可验证);
  • domain_skill_save_blocked {host, reason} —— L1-L3 拦截时记录;
  • domain_skill_fired {host, source, version} —— skill 被注入 prompt 时记录;
  • domain_skill_state_changed {host, from_state, to_state} —— 文档标注为 planned(规划中)。

隐私边界:只上报主机名,不含正文、不含 agent 文本。完全关闭可用 gstack-config set telemetry off 或环境变量 GSTACK_TELEMETRY_OFF=1

八、测试与延伸阅读

该机制的测试覆盖集中在 browse/test/ 目录,可作为行为佐证:

相关源码入口:存储层 browse/src/domain-skills.tsreadSkill / writeSkill / recordSkillUse / promoteToGlobal / rollbackSkill / deleteSkill)、CLI 层 browse/src/domain-skill-commands.ts、内容安全 browse/src/content-security.ts、ML 分类器 browse/src/security-classifier.ts。命令注册与用法说明见 browse/src/commands.tsdomain-skill 条目;项目 slug 的解析(决定 per-project 存储路径)见 browse/src/project-slug.ts

小结

Domain Skills 用一个极简的存储原语(追加写 JSONL + 墓碑 + 版本单调递增)实现了 Agent 的跨会话站点记忆,而真正的工程重心放在信任链上:quarantined 隔离态、3 次无标记才自动激活、跨项目晋升必须人工、host 强制来自浏览器受控状态、save/load 双时点过滤与 L1-L5 分层防御。它展示了一个典型的思路——当“Agent 生成的内容会进入 Agent 的未来 prompt”时,这套内容必须按外部不可信输入来设防。

登录后查看全文
热门项目推荐
相关项目推荐