Skip to content

Commit 15e36be

Browse files
furtherrefclaude
andcommitted
feat: local skill upload from folder or zip
Add a fourth creation method to the Add Skill dialog: "Upload folder or zip". Users can select local skill directories via folder picker, zip files, or drag-and-drop, preview detected skills with editable names and descriptions, and import up to 16 skills per batch. Browser-side parsing (packages/views/skills/utils/local-skill-upload.ts): - Path normalization rejecting absolute, traversal, hidden, and metadata paths - SKILL.md discovery and multi-skill grouping by nearest skill root - UTF-8 validation with fatal TextDecoder, null byte detection - Per-file (1 MiB), per-skill (128 files, 8 MiB), per-batch (16 skills) limits - Zip entry pre-inflate size check to avoid decompression bombs - Zip entries scoped to detected skill roots only Upload panel (packages/views/skills/components/local-skill-upload-panel.tsx): - Dashed drop zone with folder picker and zip picker buttons - Single-skill and multi-skill preview with editable fields - Batch selection with dynamic enable/disable at the 16-skill cap - Import progress and structured result summary (created/skipped/failed) - File input value reset for re-selection of same path - Full en + zh-Hans localization Backend API (server/internal/handler/skill_import_local.go): - POST /api/skills/import-local with per-item structured results - Server-side validation: path rules, per-file and bundle size caps, hidden/metadata file rejection, null byte sanitization - MaxBytesReader at 32 MiB to bound JSON decode memory - Partial success: duplicate names reported as skipped, not failed API client (packages/core/api/): - importLocalSkills method with Zod response schema and parseWithFallback - Malformed response graceful degradation to empty result Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c732212 commit 15e36be

25 files changed

Lines changed: 3239 additions & 12 deletions
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Local Skill Upload 实现方案
2+
3+
> 源设计文档:`docs/superpowers/specs/2026/05/12/local-skill-upload-design.md`
4+
5+
## 目标
6+
7+
在 Add Skill 对话框中增加第四种创建方式"上传文件夹或 zip",用户可从本地选择一个或多个 skill 目录 / zip 包,浏览器端解析并预览后提交到专用 API 批量导入。
8+
9+
## 架构
10+
11+
```
12+
浏览器端 服务端
13+
┌─────────────────────────────┐ ┌──────────────────────────────┐
14+
│ local-skill-upload.ts │ │ skill_import_local.go │
15+
│ · 路径规范化、安全校验 │ │ · 路径/大小/数量二次校验 │
16+
│ · SKILL.md 前置发现 │ POST │ · null byte 清理 │
17+
│ · 按 skill root 分组 │ ───────> │ · createSkillWithFiles 写库 │
18+
│ · 文件读取 + UTF-8 校验 │ /import- │ · 重复名 → skipped │
19+
│ · 跳过策略 + 预览 │ local │ · 结构化 per-item 返回 │
20+
│ · candidateToImportRequest │ └──────────────────────────────┘
21+
│ │
22+
│ local-skill-upload-panel.tsx │
23+
│ · 拖拽 / 选择文件夹 / zip │
24+
│ · 单 skill / 多 skill 预览 │
25+
│ · 可编辑名称、描述 │
26+
│ · 批量选择 (最多 16) │
27+
│ · 导入进度、结果汇总 │
28+
└─────────────────────────────┘
29+
```
30+
31+
## 模块边界
32+
33+
|| 文件 | 职责 |
34+
|---|---|---|
35+
| `packages/views/skills/utils/` | `local-skill-upload.ts` | 纯函数:路径规范化、SKILL.md 发现、文件分组、限额检查、zip 解析、drag-drop 读取 |
36+
| `packages/views/skills/components/` | `local-skill-upload-panel.tsx` | UI 组件:拖拽区、预览、编辑、导入、结果 |
37+
| `packages/views/skills/components/` | `create-skill-dialog.tsx` | 入口:增加 `upload` method card |
38+
| `packages/views/skills/lib/` | `origin.ts` | 增加 `uploaded_bundle` origin 类型 |
39+
| `packages/views/skills/components/` | `skill-columns.tsx`, `skill-detail-page.tsx` | 展示 uploaded_bundle 来源信息 |
40+
| `packages/views/locales/` | `en/skills.json`, `zh-Hans/skills.json` | i18n 字符串 |
41+
| `packages/core/types/` | `agent.ts`, `index.ts` | 共享 TS 类型 |
42+
| `packages/core/api/` | `client.ts`, `schemas.ts` | API 方法 + Zod response schema |
43+
| `server/internal/handler/` | `skill_import_local.go` | Handler:解码、校验、创建、发布事件 |
44+
| `server/cmd/server/` | `router.go` | 注册 `POST /api/skills/import-local` |
45+
46+
## 限额对齐
47+
48+
| 参数 | 客户端常量 | 服务端常量 | 说明 |
49+
|---|---|---|---|
50+
| 单文件大小 | `MAX_SKILL_FILE_BYTES` = 1 MiB | `maxLocalUploadedSkillFileBytes` = 1 MiB | 含 SKILL.md 本身 |
51+
| 每 skill 文件数 | `MAX_SKILL_FILES` = 128 | `maxLocalUploadedSkillFiles` = 128 | 不含 SKILL.md |
52+
| 每 skill 文本总量 | `MAX_SKILL_ACCEPTED_BYTES` = 8 MiB | `maxLocalUploadedSkillTotalBytes` = 8 MiB | UTF-8 字节 |
53+
| 单次导入 skill 数 | `MAX_LOCAL_SKILL_IMPORT_BATCH` = 16 | `maxLocalUploadedSkills` = 16 | |
54+
| HTTP 请求体 || `maxLocalUploadedSkillRequestBytes` = 32 MiB | 覆盖 JSON 转义开销 |
55+
56+
## 安全边界
57+
58+
- **路径校验**:客户端 `normalizeUploadPath` 拒绝绝对路径、`..` 遍历;服务端 `normalizeLocalUploadedSkillFilePath` 额外拒绝隐藏文件(`.` 前缀)、元数据文件(Thumbs.db、desktop.ini)、null 字节、Windows 驱动器路径、SKILL.md 覆盖
59+
- **UTF-8 校验**:客户端使用 `TextDecoder("utf-8", { fatal: true })`,非 UTF-8 文件跳过为 `binary_file`
60+
- **null 字节**:客户端检测并跳过含 `\0` 的文件;已接受文本中的 null 字节在提交前清除;服务端对所有字段调用 `sanitizeNullBytes`
61+
- **zip 安全**:先检查 `uncompressedSize` 再解压;仅解压属于检测到的 skill root 下的文件;跳过超限条目而非拒绝整个 zip
62+
- **请求体限制**`http.MaxBytesReader` 在 JSON 解码前截断,防止大 payload 耗内存
63+
- **文件输入重置**:选择文件后清空 `<input>` 的 value,使同一路径可重复选择
64+
65+
## 测试策略
66+
67+
### 客户端单测(Vitest)
68+
69+
**`local-skill-upload.test.ts`** — 27 个测试覆盖:
70+
- frontmatter 解析
71+
- 单/多 skill root 分组
72+
- 根级 skill bundle 支持文件保留
73+
- 源标签拼接逻辑
74+
- 嵌套 SKILL.md 归属
75+
- 隐藏/二进制/超限/遍历文件跳过
76+
- 非 UTF-8 文件跳过
77+
- 超限 SKILL.md 展示 `unreadable_skill_md`
78+
- UTF-8 字节计数(CJK 多字节)
79+
- 文件数/字节数上限短路(不读取多余文件)
80+
- zip 路径遍历拒绝
81+
- zip 超限条目标记(不解压)
82+
- zip 超限 SKILL.md 保留
83+
- zip 多 skill 独立文件计数
84+
- zip 隐藏文件不占配额
85+
- zip 忽略 root 外条目
86+
87+
**`local-skill-upload-panel.test.tsx`** — 7 个测试覆盖:
88+
- 无 SKILL.md 错误提示
89+
- 单 skill 预览和导入
90+
- zip 拖拽导入
91+
- 超过 16 个 skill 的批量选择 UX
92+
- 部分成功时保持对话框并显示结果
93+
- zh-Hans 本地化验证
94+
- 文件输入重置
95+
96+
**`create-skill-dialog.test.tsx`** — 2 个测试覆盖:
97+
- upload method card 存在性
98+
- 中文标题展示
99+
100+
**`origin.test.ts`** — 1 个测试覆盖 uploaded_bundle origin
101+
102+
**`client.test.ts`** — 3 个测试覆盖:
103+
- API 端点路径和方法
104+
- 响应 schema 解析
105+
- 畸形响应降级
106+
107+
### 服务端单测(Go test)
108+
109+
**`skill_import_local_test.go`** — 14 个测试覆盖:
110+
- 正常创建含文件的 skill
111+
- 重复名 skip + 继续
112+
- 路径遍历拒绝
113+
- 单文件超限拒绝
114+
- SKILL.md 超限拒绝
115+
- SKILL.md 路径清理拒绝
116+
- 请求体超限拒绝
117+
- 大 payload 在解码前拒绝
118+
- 超过 16 skill 拒绝
119+
- JSON 转义后仍在限额内可通过
120+
- 多 skill JSON 转义可通过
121+
- 隐藏文件路径拒绝
122+
- 元数据文件路径拒绝
123+
- 反斜杠路径遍历拒绝
124+
125+
## 风险点
126+
127+
1. **drag-and-drop 兼容性**`webkitGetAsEntry` 非标准 API,部分浏览器可能不支持目录遍历。代码已有 plain-file 降级路径。
128+
2. **zip 解压内存**:大量小文件的 zip 可能在浏览器中分配较多内存。已通过 pre-inflate size check 和 per-root file count 限制缓解。
129+
3. **JSON 转义开销**:包含大量特殊字符的文件 JSON.stringify 后体积膨胀。服务端 request cap(32 MiB)为解码限额(8 MiB × 最多 16 skill)预留了足够余量。
130+
131+
## 验收标准
132+
133+
- [x] Add Skill 对话框显示四种创建方式(manual、URL、runtime、upload)
134+
- [x] 文件夹选择器导入单个 skill
135+
- [x] zip 文件导入单个 skill
136+
- [x] 文件夹/zip 导入多个 skill,支持勾选/取消
137+
- [x] 预览中显示跳过的文件及原因
138+
- [x] 重复名报告为 skipped,不影响其他 skill
139+
- [x] 部分成功时保持对话框并展示结果汇总
140+
- [x] 隐藏文件、二进制文件、超限文件正确跳过
141+
- [x] 服务端对路径、大小、数量做二次校验
142+
- [x] API 响应通过 Zod schema 校验,畸形响应安全降级
143+
- [x] en 和 zh-Hans 本地化完整
144+
- [x] 所有单测通过

0 commit comments

Comments
 (0)