gstack Domain Skills 深度解析:Agent 自写的站点笔记如何持久化、晋升与防注入
Domain Skills 是 gstack 中浏览器 Agent 的“跨会话站点记忆”机制:Agent 在某个网站上总结出非显而易见的操作技巧后,会把笔记保存为该站点的 domain skill,后续同一站点的新会话会自动把笔记注入 prompt 上下文。本文基于 docs/domain-skills.md 与 browse/src/domain-skills.ts、browse/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)。三个关键语义:
-
新保存的 skill 一律是
quarantined,且绝不自动注入 prompt。readSkill的实现明确只返回state === 'active'(项目层)或state === 'global'(全局层)的行,quarantined 行对读取路径完全不可见。 -
自动晋升需要同时满足四个条件,由
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 重新接入,或被手动处理;这与文档描述的“设计意图”存在时差,从源码注释看属于有意保留的开关。 -
跨项目晋升(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—— 与/learnskill 共用同一个 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 描述的三条存储纪律,在源码中逐条可验证:
- 原子追加:
appendRow用O_WRONLY | O_CREAT | O_APPEND打开文件后单次writeSync加fsyncSync。源码注释说明依据:POSIX 对小于PIPE_BUF(通常 4KB)的 O_APPEND 写保证原子性,而单行 JSON 远小于该上限,因此即使并发写入也不会交错。 - 容忍式解析:
readRows逐行JSON.parse,任何一行(尤其是崩溃时刻写了一半的尾行)解析失败即静默跳过,保证“写一半崩溃”不会毒化后续所有读取;TODOS.md 中亦记录了该 JSONL 存储的后续演进方向(多写者场景下考虑 SQLite)。 - 墓碑 + 压缩:
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.ts 的 runContentFilters(body, page.url(), 'domain-skill-save'),被拦截则抛出 Save blocked: ... 并记录 domain_skill_save_blocked 遥测。该模块的四个防御层:
- L1 数据水印(Datamarking):
datamarkContent在每第 3 个句尾句号后插入由零宽空格包裹的会话级随机标记(randomBytes(3)取 base64 前 4 位),用于检测内容外泄——若这些不可见水印出现在别处,说明页面文本被原样搬运出去了。 - L2 隐藏元素剥离:
markHiddenElements在页面内检测 opacity < 0.1、字号 < 1px、屏幕外定位、前后景色相同、clip 隐藏、visibility:hidden等“人看不见、机器看得见”的元素,打上data-gstack-hidden标记后从提取文本中剔除;同时用一组注入特征正则(如ignore (previous|above|all) instructions、you are (now|a)、system:、do not follow/obey、new instructions:等)扫描 ARIA label,命中即标记。 - L3 URL 黑名单:内置一份 10 个数据外泄汇点域名的清单(
requestbin.com、pipedream.com、webhook.site、hookbin.com、requestcatcher.com、burpcollaborator.net、interact.sh、canarytokens.com、ngrok.io、ngrok-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:blank 与 chrome:// 页面,抛出文档错误表中对应的 Cannot save domain-skill: no top-level URL on active tab。
六、错误参考表
docs/domain-skills.md 的 Error reference 完整继承如下(每条错误信息在 browse/src/domain-skill-commands.ts 与 browse/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:blank 或 chrome://... |
先 $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 成功后记录(handleSave中logTelemetry调用可验证);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/test/domain-skills-storage.test.ts —— 存储层单测(追加、解析、晋升门槛、回滚等);
- browse/test/domain-skills-e2e.test.ts —— CLI 端到端测试;
- browse/test/security-classifier.test.ts —— L4 分类器加载与降级行为。
相关源码入口:存储层 browse/src/domain-skills.ts(readSkill / 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.ts 中 domain-skill 条目;项目 slug 的解析(决定 per-project 存储路径)见 browse/src/project-slug.ts。
小结
Domain Skills 用一个极简的存储原语(追加写 JSONL + 墓碑 + 版本单调递增)实现了 Agent 的跨会话站点记忆,而真正的工程重心放在信任链上:quarantined 隔离态、3 次无标记才自动激活、跨项目晋升必须人工、host 强制来自浏览器受控状态、save/load 双时点过滤与 L1-L5 分层防御。它展示了一个典型的思路——当“Agent 生成的内容会进入 Agent 的未来 prompt”时,这套内容必须按外部不可信输入来设防。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00