12-custom-skills.md 17 KB

12 自定义技能

状态:已实施(2026-10-25),一、二、三期全部完成,实施中的调整见 §11。由 FranklinWH ToolBox 新 AI 助手的需要引出(业务平台需要自己的、非公开的技能),内容是通用功能,不含业务平台专有逻辑。基于 0af6089 的现有设计(ADR-0013、0016、0017,providers/claude/skills.ts、providers/codex/skills.ts、services/community-skills.ts)。

1. 背景

1.1 现有的技能来源

来源 存放位置 如何进入 技能 ID 引擎 Profile 中
本地技能目录(local) 设置项 paths.skillsDir,生产为 /srv/agent-server/skills AgentServer 仓库 skills/ 经 deploy.sh 的 rsync --delete 发布 agent-skills:<name> 两个(Claude 按路径加载插件,Codex 经 skills/extraRoots/set) 包含在“全部技能”中
Claude 插件(plugin) Claude Code 自己的插件目录 后台“技能与插件”调用 claude plugin 安装官方市场插件 document-skills:xlsx 等 仅 Claude(ADR-0013) 包含在“全部”
Codex 官方(official) CODEX_HOME/skills/<name> 从 openai/skills 固定提交复制 openai-skills:<name> 仅 Codex(ADR-0016) 包含在“全部”
第三方(community) <dataDir>/data/community-skills skills.sh 搜索 → 预览 → 按 GitHub 固定提交安装,只收开源许可 community-skills:<name> 两个 需单独勾选(optIn)
引擎自带(system) 引擎内部 — — — —

1.2 缺口

使用 AgentServer 的业务平台(例如 ToolBox)需要自己的、非公开的技能:业务背景、工具使用流程、内部模版。现有来源都不合适:

  • 本地技能目录属于 AgentServer 仓库,部署时被 rsync --delete 覆盖;把业务技能放进去,会让通用平台夹带业务内容;把 paths.skillsDir 改到别处,又会丢掉自带的 report-conventions。
  • 第三方技能只能来自 skills.sh 收录的公开 GitHub 仓库,并要求开源许可,内部内容不适合公开。
  • 插件与官方技能只面向厂商仓库。
  • 后台没有上传技能、登记技能目录或在线编写技能的入口。

2. 目标

新增一类技能来源 自定义技能(custom),满足:

  1. 从外部导入:登记服务器上的一个目录,或上传 zip,把其中的技能导入 AgentServer;目录内容更新后可以再次同步。适用于技能由业务平台的仓库维护(如某个业务平台仓库中的技能目录)。
  2. 在后台编写:在后台直接新建、编辑技能(SKILL.md、参考文档、脚本、模版文件),保存即生效,有版本历史。适用于运营人员调整提示和规范。
  3. 与现有技能一致的使用方式:两个引擎都能用,在 Profile 中勾选,沙箱中只读,审计可查。

非目标:不做技能市场与分享;不做多管理员协作编辑与权限;不改变现有四种来源的行为。

3. 概念与存储

3.1 统一存储,两种来源

所有自定义技能都存放在 AgentServer 数据目录内,采用 Claude 插件格式(与第三方技能相同):

<dataDir>/data/custom-skills/
  .claude-plugin/plugin.json        { "name": "custom-skills" }
  skills/<name>/SKILL.md            生效版本(运行时只读这里)
  skills/<name>/references/…
  skills/<name>/templates/…
<dataDir>/data/custom-skill-store/  按内容寻址的文件库(sha256),保存各版本的文件
  • 技能 ID:custom-skills:<name>,两个引擎一致。
  • 每个技能有一个来源类型:
    • authored:后台编写,可在线编辑;
    • imported:从目录或 zip 导入,记录来源;默认只读(避免与来源不一致),可以“转为可编辑”(之后与来源脱钩,不再同步)。
  • 运行时只读 custom-skills/skills/ 下的生效版本;编辑、导入都先写入版本库,再“发布”为生效版本。这样无论来源如何,加载路径和 ID 都一致。

不采用“直接引用外部目录、实时加载”:外部目录没有插件清单时 Claude 会用目录名作前缀,ID 不稳定;目录内容随时变化,无法审阅和回滚;还要处理符号链接与越权路径。导入快照与第三方技能的做法一致(只复制普通文件、固定版本)。

3.2 技能源(导入来源)

字段 说明
id、name、description 后台显示
kind directory(服务器路径)/ upload(zip 上传,一次性)
path directory 时的绝对路径;可以是插件格式目录(skills/*/SKILL.md)、技能集合目录(*/SKILL.md)或单个技能目录(含 SKILL.md)
syncMode manual(默认)/ auto(定时检查,有变化时自动导入并发布)
intervalMin 自动同步间隔,默认 10 分钟
lastSyncAt、lastSyncDigest、status、lastError 同步状态

路径校验与资料库一致(KnowledgeService 的检测逻辑可复用):存在、服务进程能看到(受 ProtectHome 影响,home 下的路径通常不可见,需要放在 home 以外或绑定挂载)、不命中全局禁止项、不在服务数据目录与会话工作区内。

4. 功能需求

FR-1 导入与同步

  • 登记目录后点“扫描”:列出发现的技能(名称、描述、文件数、大小、与当前版本的差异:新增 / 修改 / 删除的文件)。
  • 勾选要导入的技能,确认后导入为新版本并发布。
  • “同步”:重新扫描,只显示有变化的技能和文件差异,确认后导入。auto 模式下跳过确认,直接导入并发布,审计记录变化摘要。
  • 上传 zip:解压到临时目录后走同样的扫描 → 确认流程。
  • 来源目录中删除了某个技能:同步时提示“来源中已不存在”,由管理员选择保留或删除,自动同步不自动删除。
  • 导入规则沿用第三方技能:只复制普通文件,跳过符号链接;名称取 SKILL.md frontmatter 的 name(缺省用目录名),必须匹配 ^[a-z0-9][a-z0-9-]{0,63}$;同名技能已存在且来源不同时拒绝导入。

FR-2 在线编写

  • 新建技能:填写名称、描述,生成 SKILL.md 模版(frontmatter + 章节骨架)。
  • 文件树编辑:新建 / 重命名 / 删除文件和目录;文本文件(md、txt、json、yaml、csv、py、js、sh 等)用 Monaco 编辑(后台已在 Profile 提示词中使用 Monaco);二进制文件(pptx、docx、xlsx、png 等模版)只能上传 / 替换 / 下载。
  • SKILL.md 校验:frontmatter 必须有 name(与技能名一致)、description(非空,建议 ≤ 1024 字符,过短给出提醒:描述决定技能是否被触发);正文 ≤ 200KB(与第三方技能的 MAX_SKILL_MD 一致)。
  • 保存:生成新版本(内容无变化不产生版本,与 Profile 版本历史一致),可选“保存并发布”或“保存为草稿”。草稿不影响运行时。
  • 预览:渲染 SKILL.md;显示该技能在引擎中的完整 ID 与被哪些 Profile 使用。
  • 导出:下载当前版本为 zip(便于回写到业务平台仓库)。

FR-3 版本

  • 每个技能保留全部版本:版本号、时间、来源(编辑 / 导入 / 同步 / 恢复)、改动的文件列表。
  • 查看任意版本、与当前版本对比(文本文件逐行差异)。
  • 恢复:把旧版本另存为新版本并发布(与 Profile 的“恢复保存为新版本”一致)。

FR-4 使用

  • Profile 的技能选择中出现 custom-skills:<name>,归为“自定义”分组。
  • 默认需要单独勾选(与第三方技能相同的 optIn):自定义技能通常面向特定业务,不应出现在选择“全部技能”的 Profile 中。每个技能可以设置“包含在‘全部技能’中”,默认关。
  • 技能停用:保留内容,运行时不加载,Profile 中显示为“已停用”。
  • 技能删除:二次确认;从引用它的 Profile 中移除(与删除 MCP 服务的处理一致),版本库文件随后清理。
  • Playground 中可以临时勾选自定义技能,便于编辑后立即测试。

FR-5 引擎接入

  • Claude:与第三方技能相同,custom-skills 目录作为本地插件按路径加载(PluginRef,optIn: true),由 resolveSkills 按 Profile 选择。每次对话都会启动新的 CLI 进程,发布后下一轮即生效。
  • Codex:把 custom-skills/skills 加入 skills/extraRoots/set(目前只在 app-server 启动时设置一次,providers/codex/adapter.ts)。需要在自定义技能发布后重新设置 extraRoots;已加载的会话按 ADR-0020 的方式处理:把自定义技能的版本指纹放入会话配置,变化时分叉,使新版本生效。skillsToDisable 按 Profile 选择禁用未勾选的自定义技能。
  • 沙箱:custom-skills 目录在两个引擎中只读可见(与 communitySkillsDir() 同样加入 resolveAccess 的基线可读路径);custom-skill-store 不可见。

FR-6 审计

记录:技能源的增删改、扫描与同步(含变化摘要)、技能创建 / 保存 / 发布 / 恢复 / 停用 / 删除(含版本号与改动文件列表)、zip 上传(文件名、大小、sha256)。

5. 非功能与安全

项 要求
限额 单个技能 ≤ 500 个文件、≤ 20MB(与第三方技能一致);单个文件 ≤ 10MB;zip ≤ 50MB;自定义技能总数 ≤ 200
文件安全 路径规范化,拒绝绝对路径、..、隐藏的控制字符;只写入技能自己的目录;zip 解压防路径穿越与压缩炸弹(按解压后大小计)
脚本 技能中的脚本只在 Agent 沙箱内由 Agent 执行,AgentServer 不在导入或保存时执行任何文件
原子发布 先写入临时目录,再整体替换 skills/<name>,运行中的对话不会读到一半新、一半旧的文件
并发 同一技能同时只允许一个保存 / 导入操作;编辑时以版本号做乐观锁,冲突时提示刷新
可恢复 服务重启后由数据库与版本库重建生效目录(启动时校验 skills/ 与当前版本一致,不一致则重新发布)

6. 数据模型

表 字段
custom_skills id、name(唯一)、description、origin(authored / imported)、sourceId(可空)、enabled、includeInAll、publishedVersion、draftVersion(可空)、createdAt、updatedAt
custom_skill_versions skillId、version、createdAt、reason(edit / import / sync / restore)、files(JSON:路径 → sha256、大小)、changed(相对上一版本的增删改文件)
skill_sources 见 3.2

文件内容存放在 custom-skill-store/<sha256 前两位>/<sha256>,各版本共享相同文件;没有版本引用的文件在删除技能或定期任务中清理。

7. 接口(后台 /admin/v1)

方法 路径 说明
GET / POST /custom-skills 列表 / 新建(authored)
GET / PATCH / DELETE /custom-skills/:id 详情(含文件树、版本列表、被哪些 Profile 使用)/ 改描述、启停、includeInAll / 删除
GET / PUT / DELETE /custom-skills/:id/files/*path 读 / 写(文本或 multipart 二进制)/ 删除草稿中的文件
POST /custom-skills/:id/save { baseVersion, publish } 保存为新版本,可同时发布
GET /custom-skills/:id/versions/:v、/custom-skills/:id/diff?from=&to= 版本详情 / 对比
POST /custom-skills/:id/versions/:v/restore 恢复为新版本并发布
GET /custom-skills/:id/export 下载 zip
POST /custom-skills/:id/detach 导入的技能转为可编辑
GET / POST / PATCH / DELETE /skill-sources[/:id] 技能源管理
POST /skill-sources/:id/scan 扫描,返回技能与差异
POST /skill-sources/:id/import { skills: [name], scanDigest } 按扫描结果导入(摘要不一致时拒绝,避免导入未审阅的内容)
POST /skill-sources/upload 上传 zip,返回扫描结果

GET /admin/v1/skills 的结果中加入 source: 'custom' 的技能(SkillInfo.source 增加 custom)。

8. 后台界面

在“技能与插件”页增加“自定义技能”标签(现有标签:Claude、Codex、Codex 插件、第三方):

  • 左侧:技能列表(名称、来源图标“编写 / 导入”、启停、是否有未发布草稿),顶部“新建技能”“导入”按钮;下方“技能源”列表(路径、同步模式、最近同步状态、“同步”按钮)。
  • 右侧:选中技能后显示文件树 + 编辑器;顶部为名称、描述、ID、使用它的 Profile、“保存草稿”“发布”“版本历史”“导出”;导入的技能显示来源与“转为可编辑”。
  • 导入 / 同步对话框:技能列表 + 文件差异(文本逐行)+ 确认。

9. 分期

期 内容
一期 存储与加载(两个引擎、Profile 勾选、沙箱只读);目录技能源的扫描 / 手动同步;zip 上传导入;审计
二期 在线编写(文件树、Monaco、二进制上传)、草稿与发布、版本历史与恢复、导出
三期 自动同步、版本对比视图、includeInAll 等细节

一期完成后,业务平台即可把自己仓库中的技能目录作为技能源导入(路径需对服务可见,见 3.2),或打包为 zip 上传。

10. 验收用例

编号 用例
C-01 登记一个目录技能源,扫描出 2 个技能,导入后在 Profile 中可勾选;Claude 与 Codex 的对话都能触发该技能,时间线中技能 ID 为 custom-skills:<name>
C-02 修改来源目录中的一个参考文件,同步时只显示该文件的差异;确认后下一轮对话读到新内容
C-03 来源目录中放一个指向 /etc/passwd 的符号链接和一个 ../ 路径的 zip 条目:都被跳过或拒绝,并写入审计
C-04 后台新建技能、上传一个 pptx 模版、保存为草稿:运行时不生效;发布后生效;恢复到上一版本后又回到旧内容
C-05 选择“全部技能”的 Profile 不包含自定义技能;单独勾选后才加载
C-06 Agent 尝试写入技能目录被拒绝;读取 custom-skill-store 被拒绝
C-07 删除一个被两个 Profile 使用的技能:二次确认后两个 Profile 中都已移除
C-08 服务重启后自定义技能仍可用;手动删除 custom-skills/skills/<name> 后重启,自动按当前版本恢复

11. 实施记录(2026-10-25)

代码:apps/server/src/custom-skills/(store.ts 文件库与文件工具、service.ts 技能与版本、sources.ts 技能源与 zip),后台"技能与插件 › 自定义技能"(CustomSkillsPanel.vue),测试 test/custom-skills.test.ts 覆盖 C-01 ~ C-08。

与草案不同的地方:

草案 实施 原因
文本文件用 Monaco 编辑("后台已在 Profile 提示词中使用") 等宽文本框 后台并没有使用 Monaco(Profile 提示词是普通文本框),引入会增加数 MB 体积;以后需要再换 CodeMirror 等
发布后重新设置 Codex 的 extraRoots 服务启动时就创建 custom-skills/skills,Codex 启动时把它加入 extraRoots;之后 Codex 每次列技能都会重新扫描该目录 已验证新增的技能无需重新设置即可出现;已加载会话按 ADR-0020 处理:自定义技能的发布状态指纹放进网关地址,变化时会话分叉
草稿 draftVersion 编辑中的改动保存在 custom_skills.working_files;"保存草稿"生成未发布的新版本,"保存并发布"同时发布 与 Profile 的"内容无变化不产生版本"一致,也便于"放弃改动"与"查看改动"
停用:运行时不加载 停用时整体移除生效目录,启用时按生效版本重新写入 两个引擎都只看生效目录,不需要各自过滤
删除技能源 从它导入的技能保留,转为可编辑 草案未规定;避免误删

实施中发现并修复的问题(同样影响之前的第三方技能,见 ADR-0022):

  • 内置全局禁止项包含 {dataDir}/data,技能生效目录位于其中,Agent 读不到技能的参考文档、运行不了脚本(Claude 的 Read 工具检查与 Codex 的沙箱都会拒绝)。现在只读放开服务管理的两个技能目录。
  • Codex 中父目录为 deny 时,子路径的 read 无效(write 有效)。禁止一个包含可读路径的目录时,改为逐个禁止其他内容。这也修复了宽松模式下 Codex 读不到本地技能目录、Python 运行环境与官方技能的问题。

验收:

  • Claude 实测:自定义技能被触发(时间线中为 custom-skills:e2e-lookup),Bash 读取技能的参考文件成功,读取 data/agent.db 被拒绝。
  • Codex 实测:在生产 Codex 上验证了新的权限布局(技能文件可读、不可写,同级的数据库文件不可读)。
  • 后台实测:编写、上传 zip 导入、版本对比、Profile 中勾选。