# 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:` | 两个(Claude 按路径加载插件,Codex 经 `skills/extraRoots/set`) | 包含在“全部技能”中 | | Claude 插件(plugin) | Claude Code 自己的插件目录 | 后台“技能与插件”调用 `claude plugin` 安装官方市场插件 | `document-skills:xlsx` 等 | 仅 Claude(ADR-0013) | 包含在“全部” | | Codex 官方(official) | `CODEX_HOME/skills/` | 从 openai/skills 固定提交复制 | `openai-skills:` | 仅 Codex(ADR-0016) | 包含在“全部” | | 第三方(community) | `/managed-skills/community` | skills.sh 搜索 → 预览 → 按 GitHub 固定提交安装,只收开源许可 | `community-skills:` | 两个 | 需单独勾选(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 插件格式(与第三方技能相同): ``` /managed-skills/custom/ .claude-plugin/plugin.json { "name": "custom-skills" } skills//SKILL.md 生效版本(运行时只读这里) skills//references/… skills//templates/… /data/custom-skill-store/ 按内容寻址的文件库(sha256),保存各版本的文件 ``` - 技能 ID:`custom-skills:`,两个引擎一致。 - 每个技能有一个**来源类型**: - `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:`,归为“自定义”分组。 - 默认需要单独勾选(与第三方技能相同的 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 选择禁用未勾选的自定义技能。 - 沙箱:`managed-skills/custom` 目录在两个引擎中只读可见(与 `communitySkillsDir()` 同样加入 `resolveAccess` 的基线可读路径);`custom-skill-store` 不可见。 ### FR-6 审计 记录:技能源的增删改、扫描与同步(含变化摘要)、技能创建 / 保存 / 发布 / 恢复 / 停用 / 删除(含版本号与改动文件列表)、zip 上传(文件名、大小、sha256)。 ## 5. 非功能与安全 | 项 | 要求 | | --- | --- | | 限额 | 单个技能 ≤ 500 个文件、≤ 20MB(与第三方技能一致);单个文件 ≤ 10MB;zip ≤ 50MB;自定义技能总数 ≤ 200 | | 文件安全 | 路径规范化,拒绝绝对路径、`..`、隐藏的控制字符;只写入技能自己的目录;zip 解压防路径穿越与压缩炸弹(按解压后大小计) | | 脚本 | 技能中的脚本只在 Agent 沙箱内由 Agent 执行,AgentServer 不在导入或保存时执行任何文件 | | 原子发布 | 先写入临时目录,再整体替换 `skills/`,运行中的对话不会读到一半新、一半旧的文件 | | 并发 | 同一技能同时只允许一个保存 / 导入操作;编辑时以版本号做乐观锁,冲突时提示刷新 | | 可恢复 | 服务重启后由数据库与版本库重建生效目录(启动时校验 `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//`,各版本共享相同文件;没有版本引用的文件在删除技能或定期任务中清理。 ## 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:` | | 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/` 后重启,自动按当前版本恢复 | ## 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 | 首版时后台并没有使用 Monaco;随文件管理一起引入 Monaco(按需加载,只打包常用语言),技能编辑器与 Profile 提示词都已换成 Monaco | | 发布后重新设置 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 中勾选。