PRD:Skill 详情页展示真实内容
1. 背景与问题
http://127.0.0.1:8420/#/skills/:skill_id/audit(v_skill_audit_log)目前只查询并展示
skill_iteration 表(“变更审计”面板)。当某个 Skill 还没有任何自主迭代记录时(当前所有从
Hermes profile 迁移来的 Skill 都是这种状态),页面只显示“该 Skill 尚无自主迭代记录”,用户看
不到 Skill 本身写了什么——即 skills/<skill_id>/SKILL.md 的真实文本内容。
根因:src/skill_query.py 只有 list_skills()(读 skill 表)和 get_skill_audit_log()(读
skill_iteration 表),没有任何函数读取磁盘上 skills/<skill_id>/SKILL.md 的真实内容。这批真
实文件是最近一次迁移任务才创建的(skill_update.py 旧注释“skills/<skill_id>/ doesn't exist
as real files”已过期,需在实现时一并核实/更新该注释,但不属于本次改动范围本身)。
本次修复是纯粹的“读取 + 展示”接入,不改变 Skill 迁移逻辑,不改变现有迭代审计逻辑。
2. 目标(Goals)
- G1:
v_skill_audit_log页面新增“Skill 内容”面板,与既有“变更审计”面板并列展示,用户打开任 意 Skill 详情页都能看到该 Skill 的真实SKILL.md文本内容。 - G2:后端新增只读接口,暴露 Skill 内容 + 支持文件清单,且对
skill_id这个用户可控输入做路 径穿越防护。 - G3:内容渲染方式与
v_artifact_browser保持一致(纯<pre>文本,不引入 Markdown/代码高亮 库),维持“纯 vanilla JS、无构建步骤”的既有约定。
3. 非目标(Non-goals)
- 不改动
skill_update.py/ Skill 迁移脚本的任何逻辑。 - 不改动
get_skill_audit_log()现有查询逻辑、现有/api/skills/:skill_id/audit响应契约、 现有“迭代记录”面板的 DOM 结构——两个信息源并列,不做二选一或合并覆盖。 - 不提供 Skill 内容的在线编辑/写入能力(本次是只读展示,写入仍只能走
c_skill_update/c_skill_rollback现有链路)。 - 不引入 Markdown 渲染、代码高亮或任何新的前端第三方依赖。
- 不做支持文件(
references//templates//scripts/等)的内容预览——本次只做文件清单展 示,点击查看子文件内容不在范围内(当前仓库内所有已迁移 Skill 目录下都还没有这类子文件,属 于面向未来的占位展示,先满足“看得见有哪些文件”,内容预览留待真实需求出现时再建,避免过度设 计)。 - 不做通用二进制文件预览。
4. 关键设计决策(解决 BRD 遗留的两个开放问题)
4.1 决策一:新增接口的形态
新增 GET /api/skills/:skill_id(Skill 详情接口),与已有的 GET /api/skills(列表)、
GET /api/skills/:skill_id/audit(迭代审计)并列,不复用/不修改 /audit 的响应契约。
理由:
- 参照本仓库已有先例
GET /api/artifacts/:artifact_id——列表接口返回摘要,详情接口返回完整 内容(含content字段,null表示文件不可用)。/skills/:skill_id遵循同一 REST 资源语 义:Skill 本身的详情 vs. Skill 的迭代审计子资源,职责天然分离。 - 修改
/audit的响应体形状会牵动其现有测试(SkillRouteTest)和前端既有渲染逻辑,增加不必 要的回归面;新增一个兄弟接口是零风险的纵向增量。 - 前端页面通过
Promise.all并行请求/api/skills/:skill_id与/api/skills/:skill_id/audit(参照mountArtifactsOverviewPanel已有的并行请求写法),两 个面板各自独立渲染、独立失败态,符合“两个 independent 信息源”的要求。
GET /api/skills/:skill_id 响应体:
{
"skill_id": "code-agent-engineering",
"owner_org_node": "coder",
"file_path": "skills/code-agent-engineering/",
"current_git_commit_sha": "unknown",
"source": "migrated_from_hermes",
"migrated_from_profile": "code",
"status": "active",
"content": "<SKILL.md 全文文本,UTF-8;文件不存在时为 null>",
"supporting_files": ["references/foo.md", "scripts/bar.py"]
}
- 404
{"error": "NOT_FOUND"}:skill_id在skill表中不存在(与/audit现有 404 行为保 持一致的错误形状)。 content: null:skill表中有该行,但对应的SKILL.md文件在磁盘上缺失(与v_artifact_browser中artifact.content == null的既有约定一致,不视为错误,200 返回)。
4.2 决策二:支持文件的呈现方式
supporting_files 是一个字符串数组,元素为相对 Skill 目录的相对路径(如
"references/foo.md"),按字典序排序;只列出文件本身是否存在,不读取、不返回其内容。前端只
渲染成一个纯文本清单(<ul>/<pre>,非可点击的预览入口);目录为空时显示“无支持文件”提示
文案,不请求也不渲染额外内容面板。
理由:当前仓库所有已迁移 Skill 目录下都只有 SKILL.md 一个文件,references//templates//
scripts/ 完全是面向未来的假设场景;先给出“存在哪些文件”的最小可见性即可满足 BRD 要求
(“判断要不要一并列出/展示”),把内容预览这个更大的功能面留给真实出现该场景时再单独立项,避
免为不存在的需求建可点击的子路由/子预览逻辑。
5. 功能需求(Functional Requirements)
FR-1:src/skill_query.py 新增 get_skill_content(conn, skill_id, repo_path=None)
- 复用现有
SkillNotFoundError:先按skill_id查skill表(参数化 SQL,与get_skill_audit_log()完全相同的校验前置模式),查不到直接抛出,不触碰文件系统。 - 不得用
skill_id直接拼接文件系统路径(例如os.path.join(repo_root, "skills", skill_id, "SKILL.md")这种写法是本次要杜绝的漏洞模式)。 必须复用查到的skill.file_path列值来解析真实路径——这是skill_update.py的_skill_file_path()已经在用的解析规则(.md结尾直接用,否则拼接SKILL.md)。为避免两 处实现漂移,skill_query.py应直接复用skill_update._skill_file_path(导入复用),不要重 写一份等价逻辑。 - 纵深防御:即便路径已经来自 DB 而非直接来自
skill_id,仍需在打开文件前做一次路径包含性校 验——对解析出的绝对路径和repo_root/skills的绝对路径都做os.path.realpath,确认前者位 于后者之内(os.path.commonpath或等价前缀比较,注意分隔符边界,避免skills-evil/这类前缀误判)。校验失败时抛出新增的SkillPathTraversalError,不读取任何 文件,即“失败关闭”(fail closed)。这一层是防止skill.file_path列未来被写入非预期值 (人为改库、迁移脚本 bug 等)时仍然造成越界读取。 - 读取
SKILL.md:文件存在则以 UTF-8 读取全文返回;文件不存在(DB 有行但磁盘文件缺失,例如 尚未迁移完成)时content置为None,不抛异常(对齐v_artifact_browser的“文件不可用” 既有约定)。 - 枚举支持文件:遍历 Skill 目录(
os.walk,跳过SKILL.md自身),返回排序后的相对路径列 表;目录本身不存在时返回空列表,不报错。 - 返回值:
dict,字段见 4.1 中的 JSON 结构(不含skill_id以外的 DB 字段拼装可以留给 backend handler 做,也可以在本函数里一并拼好——由实现选择,只要最终 API 响应体符合 4.1 的 字段集合)。
FR-2:src/backend_server.py 新增路由 GET /api/skills/:skill_id
- 路由匹配顺序:先匹配
.../audit和.../rollback后缀(现有逻辑不变),都不命中且路径以_SKILLS_PREFIX开头、且不等于/api/skills(列表)、不等于/api/skills/diff时,落到新 的详情 handler,把剩余路径段当作skill_id。 - Handler 调用
skill_query.get_skill_content:SkillNotFoundError→ 404{"error": "NOT_FOUND"}。SkillPathTraversalError→ 400{"error": "INVALID_SKILL_ID"}(现实中几乎不会触发,因为skill_id已先过 DB 校验;但必须存在这条分支,不能让底层异常冒泡成 500)。- 成功 → 200,响应体为 4.1 定义的 JSON,同时带上该 Skill 行的其余字段(
owner_org_node等, 复用list_skills()已有的字段读取方式)。
FR-3:前端 v_skill_audit_log(renderSkillAuditLog/mountSkillAuditLog)
renderSkillAuditLog:在既有#skill_audit_list面板之上或并列新增一个“Skill 内容”面 板,结构参照v_artifact_browser的.preview区块:html <div class="panel"> <h2>Skill 内容</h2> <div id="skill_content_body"><div class="hint">加载中…</div></div> </div>不删除、不修改任何既有#skill_audit_error/#skill_audit_list相关 DOM。mountSkillAuditLog:新增一个refreshContent(),与既有refresh()(迭代记录)各自独立 发起 fetch、各自独立渲染,二者互不阻塞(其中一个失败不影响另一个正常展示)。两者共用同一个#skill_audit_error顶部错误横幅——因为 404(Skill 完全不存在)这一失败态对两个面板都成 立,无需拆成两条错误提示。- 内容渲染:
html <pre class="code">${content == null ? "(内容文件不可用)" : escapeHtml(content)}</pre>与showPreview()里artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)完全同构,不引入任何新的渲染方式。 - 支持文件清单:
supporting_files.length为 0 时显示“无支持文件”提示文案;非空时渲染为纯文 本<ul><li>列表(escapeHtml处理),不加点击事件、不做预览入口。
6. 安全需求(路径穿越防护,对应 BRD 要求 4)
| 层 | 校验内容 | 失败行为 |
|---|---|---|
| DB 查询 | skill_id 通过参数化 SQL 查询 skill 表 |
查不到 → SkillNotFoundError → 404,不触碰文件系统 |
| 路径来源 | 文件路径必须来自 DB 的 file_path 列,不直接拼接原始 skill_id |
代码审查/测试拦截任何 os.path.join(..., skill_id, ...) 式写法 |
| 路径包含性 | 解析出的绝对路径必须位于 repo_root/skills/ 之内(realpath + 前缀校验) |
越界 → SkillPathTraversalError → 400,不读取文件 |
必须补充的测试场景(对应 §8 测试计划):向 skill 表插入一条 file_path 被人为篡改为
"../../etc/" 或 "../secrets/" 的记录,断言 get_skill_content 拒绝读取并抛出
SkillPathTraversalError,而不是把上层目录内容读出来返回。
7. 兼容性与回归范围
GET /api/skills、GET /api/skills/:skill_id/audit、GET /api/skills/diff、POST /api/skills/:skill_id/rollback四个既有接口的路由匹配顺序、响应契约、行为均不变——新 路由是在这些后缀匹配全部落空之后才命中的新分支。- 现有
src/test_skill_query.py、src/test_backend_server.py::SkillRouteTest中的既有用例必 须全部保持通过,不允许为了实现新功能而修改其断言。 skill_update.py顶部“skills/<skill_id>/doesn't exist as real files”这句过期注释,若实 现时顺手核实到已不成立,可以更新措辞,但不是本次改动的必答项,不应扩大到重构skill_update.py其他逻辑。
8. 测试计划
src/test_skill_query.py新增:- 正常路径:Skill 目录下有真实
SKILL.md,get_skill_content返回预期content文本。 - 磁盘文件缺失:DB 有行、
SKILL.md不存在,content为None,不抛异常。 SkillNotFoundError:skill_id不在skill表中。- 支持文件枚举:目录下存在
references/、scripts/等子路径时,supporting_files返回排 序后的相对路径列表;目录下只有SKILL.md时返回空列表。 - 路径穿越拒绝:
file_path被篡改为越界值时抛出SkillPathTraversalError,且断言底层没有 发生对越界路径的open()/读取(可用临时目录 + 越界哨兵文件验证内容未被读出)。
- 正常路径:Skill 目录下有真实
src/test_backend_server.py::SkillRouteTest新增:GET /api/skills/skill-1→ 200,响应体含content/supporting_files字段。GET /api/skills/no-such-skill→ 404NOT_FOUND(与/audit现有 404 行为对齐)。- 既有
.../audit、.../rollback、/api/skills/diff用例路由不受新分支影响(保持通过即 是回归验证)。
- 前端:本仓库无构建步骤、无前端单测框架(仅
frontend/test_graph_console_smoke.js这一支现 有 smoke 脚本),本次不新增前端自动化测试基建;以后端契约测试 + 手工核对docs/wireframes/v_skill_audit_log.html对应字段为验收依据,与仓库现有前端验证惯例一致。
9. 验收标准(Acceptance Criteria)
- 打开任意已有真实
SKILL.md内容的 Skill 详情页,“Skill 内容”面板显示该文件的真实全文文 本(纯<pre>,无 Markdown 渲染)。 - 该 Skill 的“变更审计”面板行为与改动前完全一致(空态文案、迭代列表、diff、rollback 均不 变)。
- 两个面板同时展示、互不覆盖、互不依赖对方是否为空。
- 磁盘
SKILL.md缺失但 DB 中有该 Skill 记录时,内容面板显示“(内容文件不可用)”,不报 500,不使页面崩溃。 skill_id不存在时,整页显示“该 Skill 不存在”错误态(两个面板都不渲染),接口返回 404。- 构造
file_path越界的 Skill 记录,接口不会读出skills/目录之外的任意文件内容,返回 400,不是 500,也不是把越界内容当成功返回。 src/test_skill_query.py、src/test_backend_server.py全量既有用例 + 本次新增用例全部通 过;未修改任何既有断言。- 前端未引入任何新的第三方依赖(无
<script src="...markdown...">之类的新增外部库引用)。