# 审计台账(完整证据) > 本文件是 `AGENTS.md` §5 的完整版,只追加不删。规则、门禁、迭代协议在 [`AGENTS.md`](../AGENTS.md)。 > 拆出原因:`AGENTS.md` 是 workspace 指令文件,有 64 KiB 注入预算上限(撞上后尾部会被静默截断)。 # 审计台账(只追加,不删) ### 5.1 台账约定 每条登记项固定字段: | 字段 | 含义 | | --- | --- | | `ID` | `D-xx`=规范落盘时已知的偏差;`A<轮次>-xx`=某轮审计新发现 | | `严重度` | 高 / 中 / 低。越权、凭据泄漏、路径逃逸、孤儿进程一律高 | | `位置` | `文件:行号`,必须实测得到 | | `规范依据` | `文件名:行号` + 原文关键措辞 | | `判定` | 违规 / 符合 / 规范未覆盖 | | `门禁` | 现有套件是否拦得住;拦不住就是门禁盲点,必须记下来 | | `状态` | 待修 / 已修 / 接受(附理由) / 待定 | 只追加不改判。已修项保留原始记录并补 `已修` 与修复 commit;判定被推翻的写新的 `A<轮>-xx` 说明推翻理由,不覆盖旧条目。 ### 5.2 第 0 轮 —— 规范落盘时记录的既有偏差 改到这些地方时不要扩大偏差,要收窄或按「处置方向」修: | ID | 严重度 | 偏差 | 规范依据 | 处置方向 | 状态 | | --- | --- | --- | --- | --- | --- | | `D-01` | 中 | `requires.contracts` 未声明 `storage.dsh/v1alpha1` / `LocalStorage`;插件自己往 `$DSH_HOME/storages/dsh-music-player.json` 写文件(消耗 `fs.write`) | `storage.zh.md:20` consumer 通过 protocol requirement 声明 | 要么改走协议 provider,要么在 manifest/README 显式说明这是自建文件存储而非协议消费 | 接受(已在 README 声明为自建存储) | | `D-02` | 低 | 权限名 `storage.local` 不是规范动作名 `storage.local.read` / `storage.local.write` | `storage.zh.md:75` | 若改用 LocalStorage 协议,拆成 `.read` / `.write` | 待定 | | `D-03` | 中 | 自建「能力 token」(`/api/dsh-music/system-art?t=…`)把 URL 里的 token 当凭证 | `permission.zh.md:82` grant record 不是可重放 bearer token、不能用 grant id 构造权限 | 不走 grant 而是自建路径凭证。必须保证:token 随机、只随鉴权过的 session 下发、不进日志(`lifecycle.zh.md:133`、`storage.zh.md:101`) | 待定(见 `A1-02`) | | `D-04` | 低 | 封面代理用主机后缀匹配(`.mzstatic.com` 等)判断资源范围 | `permission.zh.md:119` 不得用字符串前缀等未规定方法判断域名 scope | 产品侧尚无该机制时的插件内补偿栅栏;上游提供标准 scope 后迁移 | 接受 | | `D-05` | 低 | `contributes["x-dev.dsh-std.extensions"]` / 顶层 `x-dsh-transition` 是自定义扩展 | `manifest.zh.md:64` 扩展不得暗含 required 行为 | ~~已把 `browser.ui.dsh/v1alpha1` 标 `optional: true` 并补 `fallback`~~ → 判定推翻,见 `A4-02`/`A4-03`:该坐标在基线与宿主都是 0 命中(编造的),补 `fallback` 不构成修复,只让空壳声明看起来完整 | 待修(原标「已修」有误,`§5.10` 记录更正) | | `D-06` | — | `dsh-plugin.json` 不被 DSH 运行读取(0.1.7-rc.2 全仓 0 处引用) | —— | 它是生态/市场侧产物。改它不影响运行时,但改了仍必须跑 `check:manifest` | 接受(事实记录) | ### 5.3 第 1 轮 —— Lead 自审:路由注册面 + lifecycle 释放面 基线:`node scripts/run-all.mjs` → 18/18 PASS。下列 6 条没有任何一条被现有门禁拦住。 | ID | 严重度 | 一句话 | 规范依据 | 门禁 | 状态 | | --- | --- | --- | --- | --- | --- | | `A1-01` | 高 | `host.js` 实现 18 个端点,`connection.fetch` 只注册 11 个,`manifest` 只声明 10 个 —— 三方漂移,7 个端点在无 `webServer` 的宿主形态下不可达 | `manifest.zh.md:64`、`composition.zh.md:82`、`:143` | ❌ 盲点 | 待修 | | `A1-02` | 高 | 客户端「一次性回落探测」用 404 作「宿主是旧入口」的信号,而 404 在本插件是正常业务语义(无封面 / MV 缓存未命中),会被无关 404 提前烧掉;探测成功则永久降级到旧前缀,丢掉 `/api` 连接层信任栅栏 | `composition.zh.md:143`、`permission.zh.md:86` | ❌ 盲点 | 待修 | | `A1-03` | 高 | `mvJobs` 永不删除且 `dispose()` 不终止在飞 ffmpeg 子进程 —— 卸载/热重载后孤儿进程继续跑;Map 无界增长;module 级状态随 ESM 注册表每次热重载永久泄漏一份 | `lifecycle.zh.md:107`、`:105`、`:118` | ❌ 盲点 | 待修 | | `A1-04` | 中 | 热重载先 `dispose()` 旧实例再 `import()` 新实例;新实例创建失败时旧实例已销毁,无回滚/降级(仅下一次请求重试) | `composition.zh.md:143` | ❌ 盲点 | 待修 | | `A1-05` | 中 | HTTP 错误响应把 `error.message` 原样返回,泄漏本地文件名/绝对路径(如 `文件不存在:<歌名>`、Node `ENOENT ... '/Users/...'`) | `storage.zh.md:95`、`permission.zh.md:111`、`lifecycle.zh.md:133` | ❌ 盲点 | 待修 | | `A1-06` | 低 | `registerLegacyRoute` 有被双次注册的边界(`connection` 在但 `connection.fetch.register` 不可用时会与 `webServer` 回调各注册一次) | `composition.zh.md:118` 无通用「最后注册者覆盖」 | ❌ 盲点 | 待定(防御性缺口,真实 DSH 走不到) | ### 5.4 第 1 轮证据与复现 `A1-01` —— 三方漂移(实测,非静态推断) 用真实 `apply()` 抓注册面,与 `host.js` 的分派表、`dsh-plugin.json` 的 `endpoints` 对照: ``` host.js 实现 : 18 connection.fetch : 11 manifest 声明:10 ❌ 实现了但没注册到 connection.fetch (7): /api/dsh-music/caps /api/dsh-music/mv /api/dsh-music/mvconvert /api/dsh-music/mvfile /api/dsh-music/mvlib /api/dsh-music/system-art /api/dsh-music/system-stream ❌ 实现了但 manifest 没声明 (8):上述 7 个 + /api/dsh-music/session ⚠️ 注册了但 host.js 没有对应分派:无 ``` - 注册面:`lib/index.js:25-37`(`FETCH_ROUTES`);分派面:`lib/host.js:1769-2117`(18 条 `pathname === …`) - `connection.fetch` 是精确路径匹配,不是前缀:`dsh 0.1.7-rc.2` 的 `packages/client/connection/lib/index.js:625-634` 用 `fetchRoutes` Map 按 `route.path` 精确查表,重复路径直接抛 `exact Fetch route … is already registered` - 后果:`x-dsh-transition` 明确声明支持「0.1.6 形态的 desktop-host」(`webServer` 行被 `disabled: true`,只有 `connection`)。在该形态下这 7 个端点全部 404 → MV 播放、MV 进度拖动、Desktop 封面(`system-art`/`system-stream`)全断。这与 `lib/index.js:139-141` 自述的「Desktop 上这是唯一可达的通路」直接矛盾 - 门禁盲点:`scripts/test-desktop-routes.mjs:104-118` 的 `expected` 列表是手工硬编码的同一份 11 条,断言写成 `expected.every(p => routes.has(p))` —— 只查单向包含,且期望值来自注册面而非分派面,结构上无法发现这次漂移 `A1-02` —— 404 当信号 + 静默降级 - `lib/client.js:664-673`:`if (response.status === 404 && !endpointProbed) { endpointProbed = true; … }`,且 `endpointProbed = true` 写在探测之前(`:667`) - 正常业务 404 的来源:`lib/host.js:1696`/`:1715` `no embedded cover`、`:2042`/`:2094` `mv cache miss`、`:1472`/`:1474` `文件不存在:`、`:1370`/`:1466` `track not in current library` - 后果一:Web/Desktop 上第一次碰到无封面的曲目或 MV 缓存未命中,就把唯一一次回落窗口烧掉 —— `A1-01` 那 7 个端点在需要回落时救不回来 - 后果二:若探测成功,`endpointBase` 被永久切成 `LEGACY_API_BASE`(`/dsh-music/api`),而 `x-dsh-transition` 自己写明「信任边界交给连接层:Web/0.1.7 Desktop 走 `/api` 的 Host/Origin 栅栏 + 浏览器会话」—— 切成旧前缀等于放弃连接层栅栏,降级为插件自建检查。这属于 `composition.zh.md:143` 说的「悄悄把降级当正常」 `A1-03` —— 孤儿 ffmpeg + 无界增长 + 每次热重载永久泄漏 - `lib/host.js:157` `const mvJobs = new Map()` 是 module 级;全文只有 `:235` `get` 与 `:241` `set` —— `mvJobs.delete` / `.clear` / 容量上限全都没有(已 grep 确认) - `lib/host.js:2176-2192` `dispose()` 清了 `library`、封面/art/match 缓存、tag worker,完全没有触碰 `mvJobs` 或 `job.child`(已 grep 确认无 `mv`/`child`/`kill` 字样) - `:199-200` 把 `spawn()` 出的 `ChildProcess` 存进 `job.child`,无 `AbortController`、无 `kill` 路径 - 后果:(a) 卸载/热重载后 ffmpeg 继续跑到结束,往 `os.tmpdir()/dsh-music-mv` 写 `.part`;(b) `mvJobs` 每首视频一条记录,每条持 `ChildProcess` 引用 + 最多 4000 字符 `job.log`,无上限;(c) `lib/index.js:54-58` 自己说明「每次 import 都会在 ESM 注册表里留下一个无法卸载的模块条目」,而 module 级 `mvJobs` 随该条目被永久持有 —— 每次热重载永久泄漏一份 - 对照(说明这是同一模式漏了一处,不是设计取舍):tag worker 路径做对了 —— `:1281-1287` `failAllTagJobs` 清 timer + resolve + delete 条目,`:2182-2191` dispose 里 `failAllTagJobs()` + `tagWorker.terminate()`,`:1312`/`:1336` 双 `unref()` - 门禁盲点:`scripts/test-lifecycle.mjs` 覆盖面不弱(`:62` dispose 后路由摘除、`:69` 在飞扫描取消、`:82`/`:83` 5 轮无资源/句柄增长、`:84` dispose 后进程空闲),但它只驱动 `/api/library` 扫描,从不启动 MV 任务,所以测不到 `A1-04` / `A1-05` / `A1-06` 位置 - `A1-04`:`lib/index.js:67-81`(`dispose()` → `host = null` → `await import()` → `createHost()`;后两步任一抛错即 `host` 停在 `null`) - `A1-05`:`lib/index.js:91`(`failResponse`)与 `:112`(`handleRequest`)都返回 `error.message`;错误文本来自 `lib/host.js:1472`/`:1474`(`'文件不存在:' + track.name`)。Node `fs` 报错本身含绝对路径 - `A1-06`:`lib/index.js:196-202` ### 5.5 第 1 轮结论 - 现有 `npm test`(18 套,全绿)与这 6 条发现完全不相交 —— 门禁的失败面集中在「功能对不对」和「进程句柄有没有涨」,没有一条覆盖「声明面 ↔ 注册面 ↔ 分派面是否一致」。 - 因此第 1 轮最该补的不是功能测试,是一致性门禁:见 §6.3。 ### 5.6 第 2 轮 —— 门禁有效性 + 传输面覆盖 基线:`node scripts/run-all.mjs` → 18/18 PASS(Node v24.21.0,DSH 0.1.7-rc.2 / `477b4f42`)。本轮由独立审计员产出,Lead 逐条复核。 | ID | 严重度 | 一句话 | 规范依据 | 门禁 | 状态 | | --- | --- | --- | --- | --- | --- | | `A2-01` | 高 | 一个已提交进 HEAD 的 `TEMP DIAGNOSTIC` 探针把每个请求(含 query 里的能力 token)明文落盘到 `/tmp/dshm-probe.log`;该写盘不在任何已声明 permission scope 内,且挡在路由分发之前 | `storage.zh.md:101`、`permission.zh.md:55`/`:111`、`lifecycle.zh.md:133` | ❌ 盲点 | 待修 | | `A2-02` | 高 | README:270 主张的「真实 Chromium + CDP 量布局」防线在仓库里不存在;8 个客户端套件全走 jsdom | 文档与代码不符 | ❌ 盲点 | 待修 | | `A2-03` | 高 | 传输面覆盖 1/10:只有 `test-desktop-routes` 驱动 `connection.fetch`;`test-mv` 只注入 `webServer` → 「MV PASS」验证的是旧前缀通路,Desktop 的 `connection.fetch` 通路零覆盖且 5 个 MV 端点在该通路不可达 | `composition.zh.md:143` | ❌ 误导性绿灯 | 待修 | | `A2-04` | 中 | README:195「匹配套件会真实联网」与代码相反:`test-match.mjs:5` 自述 hermetic,`:45-108` stub `globalThis.fetch` | 文档与代码不符 | ❌ | 待修 | | `A2-05` | 中 | README:163「macOS 走 `~/.Trash`」与实际不符:`/api/delete` 是 `fs.unlink`(`lib/host.js:2133`)永久删除;`~/.Trash` 只在 MV 的 `deleteOriginal` 路径(`:1990-1996`) | 文档与代码不符 | ❌ | 待修 | | `A2-06` | 中 | README HTTP API 表只列 10 条,实际 18 条 —— 属 `A1-01` 在文档面的投影 | `manifest.zh.md:64` | ❌ | 待修 | | `A2-07` | 中 | 宿主侧删除流程无门禁:无套件断言一次成功删除真的删了文件 / 更新了库;`/api/delete` 越界 403 未测 | `permission.zh.md:86` | ❌ 盲点 | 待修 | | `A2-08` | 中 | 列头排序 / 搜索过滤(n/N)/ 排序记忆有代码无门禁(`sortKey`/`toggleSort`/`dshm-sorted` 在 `scripts/` 零命中) | 门禁缺失 | ❌ | 待修 | | `A2-09` | 中 | README:41 的性能数字(53MB 主线程阻塞 90ms→1ms)无门禁;`test-perf` 量的是扫描/缓存/fd/idle | 门禁缺失 | ❌ | 待修 | | `A2-10` | 低 | `test-stream.mjs:101` 断言体是字面 `true` | 恒真断言 | 该行恒绿 | 待修 | | `A2-11` | 低 | `test-mv.mjs:90`/`:101` 无 ffmpeg 时退化为 `check(…, true, 'skipped')`,门禁无法区分「跳过」与「通过」 | 条件恒绿 | 本机否 / 无 ffmpeg 时是 | 待修 | | `A2-12` | 低 | `run-all.mjs:34` 优先级 bug 吞掉 stderr(`stdout ?? '' + stderr ?? ''`),导入期崩溃的套件只显示 `FAIL ` 无诊断 | 门禁可用性 | — | 待修 | | `A2-13` | 低 | `test-audit.mjs:76` BEM 检查是唯一无非空护栏的检查,CSS 解析器失配即恒绿 | 潜在恒绿 | 本机否 | 待修 | | `A2-14` | 低 | Windows 保留设备名 / 结尾点空格过滤(`lib/host.js:1190-1201`)无测试,README:42 明确主张 | 门禁缺失 | ❌ | 待修 | | `A2-15` | 低 | `/api/apply` 串行锁(`lib/host.js:1720-1727`)无并发用例,README:42 主张消除竞态 | 门禁缺失 | ❌ | 待修 | | `A2-16` | 低 | 旧 `lib/state.json` 迁移(`lib/host.js:933-942`)无测试 | 门禁缺失 | ❌ | 待修 | | `A2-17` | 低 | 文件名序号清洗只断言「合法前导数字被保留」,清洗行为本身未断言 | 门禁缺失(部分) | ❌ | 待修 | | `A2-18` | 低 | `lib/host.js:327` 注释「差 >10s 一律不自动写入」与实现不符:`delta == 10` 时 `durationScore` 返回 `0.55` 仍放行(`:494-496`、`:555-556`) | 文档与代码不符(注释) | ❌ | 待修 | | `A2-19` | 低 | 本仓库无 `typescript`、无 `tsconfig.json`,`tsc --noUnusedLocals` 无法执行 | 约定不可执行 | — | 已修(§4) | #### 5.6.1 `A2-01` 证据(凭据落盘,实测) - 代码:`lib/host.js:1736-1757`,注释自称「TEMP DIAGNOSTIC(排查完删掉)」,`await fs.appendFile('/tmp/dshm-probe.log', JSON.stringify({ …, url: req.url, host, origin, referer, cookie: !!cookie, ua }) + '\n')`,位于 `dispatch()` 开头、路由分发之前 - 已提交:`git show HEAD:lib/host.js | grep dshm-probe` → 命中 `:1740`;`git status --porcelain lib/host.js` 为空(工作树 == HEAD `389b6e5`) - 实机文件:`/tmp/dshm-probe.log` 549842 字节 / 2620 行,`grep -c 'system-\(art\|stream\)?t='` → 5 行含能力 token;`"via"` 分布 `legacy=2495 / fetch=120 / system-art=5` - scope 越界:`dsh-plugin.json` 的 `fs.write` scope 是「当前音乐目录内、且在播放列表中的音频文件」,`storage.local` scope 是 `$DSH_HOME/storages/…` + localStorage —— `/tmp` 两者都不是 → 违反 `permission.zh.md:55` - 安全属性:文件权限 `-rw-r--r--`(同机其他用户可读) #### 5.6.2 `A2-03` 证据(为什么 18/18 全绿却仍漏掉 `A1-01`) 逐套件统计「驱动哪条传输面」(`grep -c connection` / `grep -c webServer`): ``` test-desktop-routes.mjs connection=10 webServer=5 ← 唯一驱动 connection.fetch test-stream/security/perf/lifecycle/ match/apply/mv/hot-reload connection=0 webServer=1 ← 全靠旧 /dsh-music 前缀 test-progress/resume/refresh-remap/ restore-race/teardown/delete/ client-shell/match-client/audit connection=0 webServer=0 ← 纯 jsdom,打桩 fetch ``` `test-mv.mjs:50` 只注入 `webServer: { register }`,没有 `connection` → 它验的是旧前缀通路。而 `mv`/`mvfile`/`caps`/`mvlib`/`mvconvert` 不在 `FETCH_ROUTES` 里,`connection.fetch` 又是精确匹配 → Desktop 走的那条通路既不可达、又零覆盖。客户端确实在调它们(`lib/client.js:1136-1138`、`api("/api/mv?id=")`)。这解释了 `A1-01` 为什么能在「MV 套件 PASS」的同时为真。 #### 5.6.3 Lead 自身错误(本轮建立,必须单列) | ID | 我做了什么 | 为什么错 | 已修 | | --- | --- | --- | --- | | `E-01` | 在 README「依赖、权限与失败边界」里写「`fs.delete`:删除曲目(macOS 走 `~/.Trash`)」 | 我看到 `host.js:1992` 有 `~/.Trash` 字符串就当成删除路径,没核实它属于 MV 的 `deleteOriginal` 分支;实际 `/api/delete` 是 `fs.unlink` 永久删除(且与 README:17 自相矛盾) | README 待修(`A2-05`) | | `E-02` | 在 README 里写「`npm test` 中的匹配套件会真实联网……不是离线测试」 | 我只看了套件名字("online metadata match")就下结论,没读 `test-match.mjs:5` 的 `hermetic` 自述与 `globalThis.fetch` 桩 | README 待修(`A2-04`) | | `E-03` | 在 AGENTS.md §4 把 README:270 的「真实 Chromium + CDP 量布局」写成既有防线 | 把 README 的自我描述当成事实,没验证仓库里有没有浏览器启动器 | ✅ 已修 §4(标注为 ❌ 不存在) | | `E-04` | 在 AGENTS.md §4 写「改完用 `tsc --noUnusedLocals --noUnusedParameters` 扫一遍」 | 抄自 `.jspace/WORKSPACE.md` 的历史条目,没核实 `typescript`/`tsconfig.json` 是否存在 | ✅ 已修 §4(标注为不可执行) | > 教训(已沉淀为 §6.1 第 2 条):README 的自我描述不是事实来源。 任何「本仓库已有 X 防线 / 已有 Y 约定」的说法,引用前必须实测存在(`glob`/`grep`/实跑)。 #### 5.6.4 第 2 轮结论 现有门禁的骨架真实有效(18/18 实跑通过;失败能真的非零 —— 负向对照 `DSH_MUSIC_HOST=/dev/null node scripts/test-perf.mjs` → exit 1;18 个 `test-*.mjs` 与 `run-all.mjs` 的 `suites` 集合完全一致;静态审计 6 项与按钮接线防线均落地;封面策略 / 安全栅栏 / 生命周期有硬断言)。但它强在「功能对不对」与「句柄涨不涨」,在三个面是空的: 1. 声明面 ↔ 注册面 ↔ 分派面一致性(`A1-01`) 2. 传输面覆盖 —— 1/10 套件打在生产使用的通路上(`A2-03`),这使第 1 类缺陷表现为绿灯 3. module 级容器与子进程的释放(`A1-03`) ### 5.7 第 3 轮 —— Lifecycle 释放面(客户端 + 宿主) 基线沿用 §5.3 的 18/18 PASS(本轮只读,未重跑)。独立审计员逐行读 `lib/index.js` / `lib/host.js` / `lib/client.js` / `lib/tagwriter.js` / `lib/http-bridge.js` 及 cordis、`dsh-client-runtime` 的 vendored 实现。 | ID | 严重度 | 一句话 | 规范依据 | 门禁 | 状态 | | --- | --- | --- | --- | --- | --- | | `A3-01` | 高 | 客户端库轮询定时器在停用后自我续期:`halt()` 只在 `pollTimer !== null` 时能清;若 teardown 撞上某次轮询在飞(回调开头已把 `pollTimer` 置 null),该请求 404 → `catch { schedulePoll(); }` 再武装,而 `halt()` 的 `set()` 不重置 `scanning` → `if (!state.scanning) return` 永不成立 → 永久 1.5s 请求风暴 | `lifecycle.zh.md:107`、`:29` | ❌ 盲点 | 待修 | | `A3-02` | 中 | 卸载与在飞 `ensureHost` 竞态:`ensureHost` 有两个 await 挂起点,teardown effect 只把 `host = null`,无 `disposed` 标志;挂起的 IIFE 恢复后照样 `host = module.createHost(ctx)` → 产生无人持有的新 host(会 `startScan` + 可能拉起 tag worker),此后再也没人 `dispose()` 它 | `lifecycle.zh.md:29`、`:107` | ❌ 盲点 | 待修 | | `A3-03` | 中 | 客户端 player 经 `window.__dshMusicPlayer` 跨 activation 复用:bundle HMR 换代后新 module 不再 `createPlayer()`,直接接管旧 closure 的全部 handler / timer / `playingId`;新旧 activation 的 disposer 操作同一个 player → 旧 disposer 会对新实例仍在用的对象调 `halt()` | `lifecycle.zh.md:125`、`:127` | ❌ 盲点 | 待修 | | `A3-04` | 中 | 停用后 `document.body` 上的自建 DOM(`.dshm-mvPark` + `