# 18 技能发布平台(推送 + 审核) > 状态:**一期已实施(2026-11-03)**,决策见 ADR-0027,实施中的调整见 §12。修改自定义技能([12-custom-skills.md](12-custom-skills.md))的来源:去掉目录技能源与上传 zip,改为"项目推送、管理员审核",保留后台编写。 ## 1. 背景 技能通常由其他项目生成或维护(业务平台仓库、工具项目等),这些项目在别的用户目录、别的机器或 CI 中。现有的两种导入方式都不合适: - 目录技能源:服务以专用账号运行并启用了 `ProtectHome`,读不到 `/home`,只能手动拷贝到服务能看到的位置; - 上传 zip:每次都要手动打包、登录后台上传,无法接入项目的构建流程,也说不清技能从哪个项目、哪次提交来。 ## 2. 目标 1. 项目通过接口**推送**技能包,服务器不需要能访问项目所在的位置; 2. 推送的内容**审核通过后才能使用**;审核时决定新建技能还是覆盖已有技能,覆盖前看清差异并再次确认; 3. 密钥简单、好管理:不引入"发布者",用密钥名称表示来源; 4. 自定义技能只有两种产生方式:**后台编写**、**推送并审核**。 不在本期:正式的客户端工具(先给 curl 示例)、公开目录、跨实例同步。 ## 3. 概念 | 概念 | 说明 | | --- | --- | | 发布密钥 | `sk_pub_…`,由管理员在后台创建。**名称**就是来源标识,例如"fwh-app · GitHub Actions""张三 · 本机"。只能调用发布接口,不能创建会话或访问其他数据。 | | 提交 | 一次推送:一个技能包(可以包含多个技能)、提交说明、来源信息(仓库、分支、提交号,自由字段)。内容不可修改。 | | 提交项 | 提交中的一个技能,是审核的单位:每个技能单独批准或拒绝。 | 不设"发布者",也不限定技能名:推送不会直接改变任何技能,新建还是覆盖、用什么名字,都由管理员在审核时决定。 ### 3.1 提交项的状态 ``` ┌── 批准:新建技能 ──────────────┐ pending ────────┤ ├──▶ approved(记录生成的技能与版本) ├── 批准:覆盖已有技能(再次确认)┘ ├── 拒绝(填写原因)────────────────▶ rejected ├── 撤回(推送方)──────────────────▶ withdrawn └── 同一密钥又推送了同名技能 ────────▶ superseded ``` - 提交时就能判定的不进入审核:校验不通过为 `invalid`(附原因);与同名技能的当前版本完全相同为 `unchanged`。 - 同一密钥推送同名技能时,之前待审核的那一份变为 `superseded`,管理员只审核最新的一份。 - 管理员可以**删除**已处理的提交项(approved、rejected、withdrawn、superseded、invalid、unchanged)。删除只删提交记录,不影响已生成的技能版本:版本历史中保留提交说明与密钥名称的副本。待审核的提交项需要先批准或拒绝。 ### 3.2 与自定义技能的关系 - 自定义技能只有一种来源类型:后台可编辑。推送来的技能审核通过后与后台编写的技能相同,可以直接在后台修改。 - 下一次推送覆盖时,审核页的差异会显示管理员在后台改过的内容(这些改动会被覆盖),由管理员决定是否覆盖。 - 版本号仍由平台分配(v1、v2…);SKILL.md 中声明的版本号(`version` / `metadata.version`)只用于展示和对比。 - 版本历史中标出每个版本的来源:"编辑""恢复""来自提交 sub_…(fwh-app · GitHub Actions):提交说明"。 ## 4. 功能需求 ### FR-1 推送 - 推送方用发布密钥调用接口上传技能包(zip:单个技能、技能集合 `<名称>/SKILL.md` 或插件格式 `skills/<名称>/SKILL.md`),可附提交说明、来源信息。 - 服务端解压、逐个技能校验(只取普通文件、名称规则、大小与数量限额、SKILL.md 校验),文件存入现有的文件库,生成提交记录,立即返回每个技能的状态。 - **试运行**(`dryRun=1`):只校验、不生成提交,便于在 CI 中先检查。 - 幂等:同一密钥推送与待审核内容完全相同的技能,返回已有的提交项,不重复生成。 - 限额:每个密钥每小时最多 30 次提交,待审核的提交项最多 50 个(可在设置中调整)。 ### FR-2 自动检查(辅助审核,不自动拒绝) | 检查 | 说明 | | --- | --- | | 结构 | SKILL.md 的 name / description、文件数、大小(不通过即 `invalid`) | | 可执行内容 | 列出脚本文件(`.sh`、`.py`、`.js` 等)及其变化,是审核重点 | | 疑似密钥 | 按常见格式匹配(各类 API Key、私钥、令牌、连接串),命中时标红 | | 外部地址 | 列出 SKILL.md 与脚本中出现的网址 / 主机名 | | 二进制文件 | 列出(模版等),可以下载查看 | | 声明版本号 | 与同名技能的当前版本对比,没变但内容变了时提示 | ### FR-3 审核 待审核列表:技能名、密钥名称、提交说明、来源信息、提交时间、检查结果摘要;是否已有同名技能。 打开一个提交项,先查看内容(文件树、SKILL.md、检查结果),然后选择: **批准为新技能** - 技能名默认取 SKILL.md 中的 name,管理员可以修改(同名技能已存在时必须改名,否则请选择"覆盖"); - 改名时自动把 SKILL.md frontmatter 中的 name 改为新名称(审核页显示这一处改动); - 可选"批准并发布"(默认)或"批准但暂不发布"(先在 Playground 中试用,之后在技能详情中发布)。 **覆盖已有技能** - 目标技能默认是同名技能,也可以选择其他技能(名称不同时同样改写 SKILL.md 中的 name); - 显示新旧差异:逐文件对比(新增 / 修改 / 删除),以目标技能的**最新版本**为准,并单独提示: - 目标技能有尚未保存的编辑(覆盖后丢失); - 目标技能在推送之后有新版本(例如管理员刚修改过); - 声明版本号的变化(如 2.0.0 → 2.1.0,或没变); - 点击"覆盖"后弹出二次确认("将用这次提交覆盖 fwh-assistant(v3 → v4),以上改动会生效"),确认后生成新版本;旧版本可以在版本历史中恢复。 - 同样可选立即发布或暂不发布。 **拒绝** - 必须填写原因;推送方可以通过接口看到状态与原因。 - 已拒绝的提交项仍可在"已处理"中查看完整内容(文件、检查结果、原因),可以删除。 ### FR-4 推送方查询 推送方可以查询用自己的密钥提交的内容:状态、审核意见、生成的技能名与版本号;可以撤回仍在待审核的提交项。看不到其他密钥的提交,也不能下载平台上的技能。 ### FR-5 密钥管理 - 后台新建密钥:名称(必填,表示来源)、说明、有效期(默认 1 年,可选永久)。明文只在创建时显示一次,之后只显示前缀。 - 可以停用 / 删除;显示最近使用时间与来源 IP、提交数、待审核数。 - 删除密钥不影响已经批准的技能;它的待审核提交项保留,由管理员处理。 ### FR-6 审计 推送(密钥名称与前缀、包大小与 sha256、来源信息)、批准(新建 / 覆盖、技能名、版本号)、拒绝(原因)、撤回、删除提交记录、密钥的增删改都记入审计日志。 ## 5. 密钥的保管 发布密钥的权限很小:**只能产生待审核的提交**,不能直接改变任何技能。即使泄露,最坏的结果是出现一些需要拒绝的提交,在后台吊销即可。因此不需要复杂的密钥系统,按使用场景放在项目代码之外: | 场景 | 保管方式 | | --- | --- | | CI(GitHub Actions、GitLab CI 等) | 存为 CI 的加密变量(secret),如 `AGENT_SKILLS_TOKEN`;一个项目的流水线一个密钥,名称写明项目与 CI | | 开发者本机 | 存在用户目录的配置文件(如 `~/.config/agent-skills/credentials`,权限 600)或环境变量;一人一机一个密钥,名称写明人与机器 | | 项目仓库 | **不放密钥**;只放推送地址等不敏感的配置(如 `.agent-skills.json` 中的 `server`、技能目录) | 推送工具(后续实现)按"环境变量 → 用户配置文件"的顺序读取密钥,项目之间无需各自保存。 二期可以增加**浏览器登录**,免去复制密钥:推送工具执行 `login` 时显示一个验证码并打开后台页面,管理员确认后,平台为这台机器签发一个密钥并写入用户配置文件(与 `gh auth login` 的设备码流程相同)。 ## 6. 接口 ### 6.1 发布接口(发布密钥,`Authorization: Bearer sk_pub_…`) 与服务同一端口,前缀 `/skills/registry/v1`。 | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/submissions` | multipart:`file`(zip)、`message`、`meta`(JSON,如 `{"repo","branch","commit"}`);`?dryRun=1` 只校验。返回提交与各技能的状态 | | GET | `/submissions?status=&limit=` | 用这个密钥提交的列表 | | GET | `/submissions/:id` | 详情:各技能的状态、检查结果、审核意见、生成的技能名与版本号 | | DELETE | `/submissions/:id` | 撤回其中仍在待审核的技能 | 提交的返回示例: ```json { "id": "sub_01k…", "status": "pending", "items": [ { "id": "sbi_01k…", "name": "fwh-assistant", "status": "pending", "declaredVersion": "2.1.0", "existing": { "version": 3, "declaredVersion": "2.0.0" }, "checks": [{ "level": "warning", "code": "script", "message": "包含脚本 scripts/build.py" }] }, { "id": "sbi_01k…", "name": "fwh-report", "status": "unchanged" } ] } ``` 审核后: ```json { "id": "sbi_01k…", "name": "fwh-assistant", "status": "approved", "result": { "action": "overwrite", "skill": "fwh-assistant", "version": 4, "published": true } } { "id": "sbi_01k…", "name": "fwh-draft", "status": "rejected", "reviewNote": "脚本中包含测试环境地址,请改为参数" } ``` CI 中推送示例(正式的推送工具后续再定): ```bash cd skills && zip -qr /tmp/skills.zip . && \ curl -fsS -H "Authorization: Bearer $AGENT_SKILLS_TOKEN" \ -F file=@/tmp/skills.zip -F message="$(git log -1 --pretty=%s)" \ -F meta="{\"repo\":\"fwh-app\",\"commit\":\"$(git rev-parse HEAD)\"}" \ https://agent.zenpower.cc/skills/registry/v1/submissions ``` ### 6.2 后台接口(`/admin/v1`) | 方法 | 路径 | 说明 | | --- | --- | --- | | GET / POST / PATCH / DELETE | `/skill-keys[/:id]` | 发布密钥(新建时返回一次明文) | | GET | `/skill-submissions?status=pending\|done` | 提交项列表 | | GET | `/skill-submissions/:itemId` | 详情:文件清单、检查结果、同名技能信息 | | GET | `/skill-submissions/:itemId/file?path=` | 查看提交中的文件 | | GET | `/skill-submissions/:itemId/diff?target=<技能ID>&name=` | 与目标技能最新版本的差异(含改名对 SKILL.md 的改动) | | POST | `/skill-submissions/:itemId/approve` | `{ action: 'create', name, publish }` 或 `{ action: 'overwrite', target, expectedVersion, publish }` | | POST | `/skill-submissions/:itemId/reject` | `{ reason }` | | DELETE | `/skill-submissions/:itemId` | 删除已处理的提交项 | 覆盖时提交 `expectedVersion`(审核页看到的目标技能版本号):目标技能在此期间又有新版本时拒绝并提示刷新,保证覆盖的就是对比过的那个版本。 ## 7. 数据模型 新增: | 表 | 主要字段 | | --- | --- | | `skill_publish_keys` | `id`、`name`、`description`、`keyHash`、`keyPrefix`、`enabled`、`expiresAt`、`createdAt`、`lastUsedAt`、`lastUsedIp` | | `skill_submissions` | `id`、`keyId`、`keyName`(副本,密钥删除后仍可显示)、`message`、`meta`(JSON)、`packageSha`、`packageSize`、`createdAt` | | `skill_submission_items` | `id`、`submissionId`、`name`、`files`(文件清单,内容在文件库中)、`description`、`declaredVersion`、`checks`(JSON)、`status`、`reviewNote`、`reviewedAt`、`result`(JSON:新建 / 覆盖、技能 ID、版本号、是否发布) | 修改: - `custom_skill_versions` 增加 `note`(JSON:提交 ID、密钥名称、提交说明),`reason` 增加 `submission`。 - 去掉技能源:删除 `skill_sources` 表;`custom_skills` 的 `origin`、`sourceId` 不再使用(已有技能全部转为可编辑)。 提交内容直接存入现有的文件库(按内容寻址,相同文件只存一份)。已删除的提交项引用的文件,若不再被任何版本或提交引用,定期清理。 ## 8. 去掉目录技能源与上传 zip - 删除后台"技能源"区域、"上传 zip 导入"按钮,以及对应的接口、自动同步定时任务、上传暂存目录。 - 已有的从技能源导入的技能转为可编辑,版本历史保留。 - 保留:后台新建 / 编辑技能、技能内单个文件的上传与替换、导出 zip、版本历史与恢复。 - 目录选择框继续用于资料库登记。 ## 9. 后台界面 "技能与插件 › 自定义技能"左侧: - 技能列表(新建技能); - "待审核 (N)":提交项列表,数字同时显示在导航"技能与插件"旁; - "已处理":已批准、已拒绝等提交项,可以查看与删除; - "发布密钥":名称、前缀、有效期、最近使用、提交数;新建 / 停用 / 删除。 审核视图(右侧): - 顶部:技能名、密钥名称、提交说明、来源信息、提交时间; - 检查结果;文件树与文件内容; - 底部操作:"批准为新技能"(技能名输入框)/ "覆盖已有技能"(选择目标,显示差异,点击后二次确认)/ "拒绝"(填写原因);"立即发布"勾选框默认勾选。 ## 10. 安全 - 发布密钥只哈希存储,有有效期;只能访问发布接口,只能看到自己的提交。 - 未经批准的内容**不会写入生效目录**,引擎看不到;批准时按提交项保存的文件清单生成版本,提交后内容不可修改,因此批准的就是审核时看到的内容。 - zip 解压沿用现有的防护(路径穿越、压缩炸弹、符号链接);包大小与技能限额与原上传 zip 相同(zip ≤ 50MB,单个技能 ≤ 500 个文件、≤ 20MB)。 - 推送接口有频率与待审核数量限制。 ## 11. 分期 | 阶段 | 内容 | | --- | --- | | 一期 | 发布密钥、推送接口(含试运行)、自动检查、审核(新建 / 覆盖 / 拒绝)、已处理记录与删除、推送方查询、去掉技能源与上传 zip、审计 | | 二期 | 推送工具(命令行,读取环境变量或用户配置)、浏览器登录签发密钥、审核结果通知 | ## 12. 实施记录(2026-11-03) 代码:`apps/server/src/custom-skills/registry.ts`(密钥、推送、检查、审核)、`package.ts`(解压与识别技能目录)、`http/routes/registry.ts`(发布接口);协议文档 `api-reference/registry-reference.ts`(后台"API 协议 › 技能发布 API",可填入发布密钥直接调试,也包含在下载的 PDF 中;测试核对文档与实际路由一致);后台 `SkillReviewPane.vue`、`SkillPublishKeys.vue`;测试 `test/custom-skills.test.ts`(C-01 ~ C-05)。迁移 `0019_skill_registry`、`0020_drop_skill_sources`。 与上文的差异: | 设计 | 实施 | 原因 | | --- | --- | --- | | 幂等:重复推送相同内容返回已有的提交项 | 每次推送都生成提交;同一密钥的同名技能替代之前待审核的那一份 | 结果相同(只审核一份),不需要额外的去重逻辑 | | 推送限额可在设置中调整 | 固定为每个密钥每小时 30 次、待审核 50 个(`REGISTRY_LIMITS`) | 一期先固定,需要时再放进设置 | | 待审核数量显示在后台导航 | 显示在"技能与插件 › 自定义技能"标签上,左侧"待审核 (N)" | 导航只在分组层级,标签上更直接 | | 被拒绝、撤回的提交项保留 90 天后清理 | 由管理员在"已处理"中删除;删除后不再被引用的文件随之清理 | 数量不大,避免自动删除审核记录 | | 旧的技能源数据 | 迁移删除 `skill_sources` 表和 `custom_skills.origin / source_id`,所有技能变为可编辑 | 一并移除技能源 |