prd.md 14.1 KB

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()/读取(可用临时目录 + 越界哨兵文件验证内容未被读出)。
  • src/test_backend_server.py::SkillRouteTest 新增:
    • GET /api/skills/skill-1 → 200,响应体含 content/supporting_files 字段。
    • GET /api/skills/no-such-skill → 404 NOT_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)

  1. 打开任意已有真实 SKILL.md 内容的 Skill 详情页,“Skill 内容”面板显示该文件的真实全文文 本(纯 <pre>,无 Markdown 渲染)。
  2. 该 Skill 的“变更审计”面板行为与改动前完全一致(空态文案、迭代列表、diff、rollback 均不 变)。
  3. 两个面板同时展示、互不覆盖、互不依赖对方是否为空。
  4. 磁盘 SKILL.md 缺失但 DB 中有该 Skill 记录时,内容面板显示“(内容文件不可用)”,不报 500,不使页面崩溃。
  5. skill_id 不存在时,整页显示“该 Skill 不存在”错误态(两个面板都不渲染),接口返回 404。
  6. 构造 file_path 越界的 Skill 记录,接口不会读出 skills/ 目录之外的任意文件内容,返回 400,不是 500,也不是把越界内容当成功返回。
  7. src/test_skill_query.py、src/test_backend_server.py 全量既有用例 + 本次新增用例全部通 过;未修改任何既有断言。
  8. 前端未引入任何新的第三方依赖(无 <script src="...markdown..."> 之类的新增外部库引用)。