diff --git a/docs/plan/07-v0.1-final-review.md b/docs/plan/07-v0.1-final-review.md new file mode 100644 index 00000000..dcffd45c --- /dev/null +++ b/docs/plan/07-v0.1-final-review.md @@ -0,0 +1,290 @@ +# v0.1 第二轮独立验收审查(发布前终审) + +审查日期:2026-09-28。基线 HEAD `36aa99b`(工作区只有 `deno.lock` 未提交改动)。只读审查,本文件是唯一写入。 +对照:`04-v0.1-acceptance.md`(第一轮)、`00` §3.1 与 §6、`02`、`03`、`05`、`06`、`docs/design/agent-ui-reference.md`、`docs/porting/{mp-rust-logic-parity,moviepilot-rust-transfer}.md`、README、runbook、CHANGELOG。 + +**约束说明**:W6-A 正在独占修 e2e 稳定性,本轮**没有跑全量 `deno task ci`,也没有跑依赖 PG 的测试**。依赖 PG 的测试(server e2e、file-manager、acquisition-organize `__tests__`)只读代码核对断言,没有实际运行。 + +本轮实测(均通过): + +| 项 | 结果 | +|---|---| +| 单包 vitest(不依赖 PG) | domain 510、contracts 589、adapters 264(另有 3 个 skip)、mapping 22、agent 31、web 27;server 里不依赖 PG 的 4 个文件(api-registry / logger / invoke-limits / health)共 56 个 | +| 类型检查 | `apps/server`、`apps/web` 的 `deno task typecheck` 退出码 0 | +| biome | 763 个文件,0 个 error;1 个 warning(`subscription.test.ts:132` 的 `dt2` 未使用),1 个 info(`subscriptions-policy.tsx:420` 有多余的 fragment) | +| 实机 `PORT=4396 --role api`(测完已关) | `/health` 返回 `version:"0.1.0"`;`/files/roots`、`/files`、`/files/trash`、`/engine/runs` 无凭证时 401;`path=../etc` 返回 422;用 DEV_ADMIN_KEY 的 Bearer 调 `POST /approvals` 返回 403 | +| 提交规范 | 82 个提交全部符合 `type(scope): 描述`;还没有任何 tag | + +## 0. 结论 + +**前提是 W6-A 把 e2e 治稳、`deno task ci` 连续两次全绿。在这个前提下,修完 1 个阻塞项(只改文档)就可以打 `v0.1.0`。** + +- 第一轮的 4 个阻塞项全部复核落地。11 个应修项里 10 个已验证,S9(控制台 TOTP 入口)仍缺。 +- §6 追加范围: + - 文件管理:后端 18 个端点齐全,规格 §4.1 的 7 条不变量都有代码和测试对应。 + - 订阅增强:05 清单 P0 的 1–7 条全部完成,第 8 条部分完成。 + - Agent 页:agentui 映射表 11 行全部落地。 + - 转移语义:5 项全部落地,挂死隔离在生产二进制里生效有容器演练记录。 + - 差分补迁:missing 和 our_bug 已清零。 +- 阻塞的是**发布说明**:CHANGELOG `[0.1.0]` 完全没写 W4-E4、W4-F、W5-A~E,"已知限制"里还有一条已经过时的表述。 +- 安全面没有发现需要阻塞发布的漏洞。有 1 个多源 Move 的授权范围缺口(应修 S-1),危害比第一轮 R4 小:只能在已登记的根之间搬运,而且资产位置会跟着更新。 + +## 1. 问题清单 + +### 1.1 阻塞发布(1) + +| # | 问题 | 位置 | 修法 | +|---|---|---|---| +| B1 | CHANGELOG `[0.1.0]` 只写到 W4-E3。缺少 W4-E4(转移语义)、W4-F(Agent 会话页)、W5-A/A2/B(文件管理)、W5-C(订阅增强)、W5-D(识别差分补迁)、W5-E(生产 fsworker、`autoInvestigateMode` 设置)。"已知限制"第 3 条("没有来源会把集单元确认……Slot 不会进入 satisfied")在 W5-C 引入总集数账本后已经不成立 | `CHANGELOG.md` | 按波次补 W4-E4 到 W5-E 的条目;把"已知限制"换成本文 §8 的清单(或直接链接本文),删掉过时的第 3 条 | + +### 1.2 应修(v0.1.x,10 项) + +| # | 问题 | 位置 | 修法 | +|---|---|---|---| +| S-1 | `/files/move` 用 API Key 执行时,只取 `sources[0]` 对应的作品去核对持续授权。只要持有覆盖作品 A 的 grant,就能在同一请求里把作品 B 的已登记文件一起 Move 走 | `file-manager/transfer.ts:236-244` | 对每个源(目录按其中已登记资产)求出作品集合,要求每个作品都被 grant 覆盖;有无法归属作品的源时只接受一次性审批。补一条"两个作品只授权一个 → 403"的测试 | +| S-2 | 注册表的鉴权标注又和实际行为不一致了(第一轮 S10 的回归)。`GET /files/download` 和 `POST /files/trash/purge` 在处理器里只放行管理员会话,但注册表只标了 `scope:"admin"`,没有标 `auth:"session"`,所以 api-registry 的双向测试没有覆盖到它们。另外 `isAdmin` 放行 `dev_admin`,而 `auth:"session"` 的端点会拒绝 DEV key,两处口径不同 | `packages/contracts/src/api.ts:1150,1182`;`file-manager/common.ts isAdmin` | 两个端点标 `auth:"session"`,在 `api-registry.test` 的会话端点列表里加上;确定 DEV key 的口径,两处统一 | +| S-3 | 第一轮 S9 仍未做:`useTotpStatus` / `useTotpAction` 写好了,但界面里没有任何地方调用,账户弹层只有改密和登出 | `apps/web/app/components/console/account-menu.tsx` | 账户弹层加"二次验证"开关,接 `GET/POST /auth/totp`;04 表里 S9 的状态同步更新 | +| S-4 | 脱敏键表还是三份(见 §5.4)。rss 和 browser 两处都漏了 `c_secure_pass`、`pass_key`/`auth_key` 这类带分隔符的写法、`refresh_token`、`password`、`uid_key`;browser 另外漏了 `auth`。adapters README 写的是"键表与规则取 contracts 的 redact.ts",与实际不符 | `packages/adapters/src/rss/redact.ts:9`、`browser/redact.ts:13` | rss/browser 的参数判定改为调用 `@umt/contracts` 的 `isSensitiveKey` 和 `SENSITIVE_QUERY_PARAMS`,各自只保留特有规则(路径式 passkey、cookie 名、a11y 密码框);加一个三处一致性测试 | +| S-5 | 回收站"还原到…"不检查目标根是否可写,可以还原进 `register_only` 库根。父目录不存在时,会先按 `resolve()` 递归 mkdir,再做 realpath 复核;如果中间一段目录是指向根外的符号链接,根外可能已经被建出目录 | `file-manager/trash.ts:478-506` | target 走 `isWritableTarget`;原位还原时先逐级 realpath 已存在的祖先目录,再 mkdir | +| S-6 | `/files/identify` 走 `capabilities.invoke("catalog.lookup")`,绕开了 `invoke-limits` 的 TMDB 并发限制。每项超时 15 秒;目录最多展开 200 个视频,在 HTTP 请求里串行 | `file-manager/identify.ts:150-160` | 复用 `createInvokeLimiter` 的 TMDB 信号量;单次请求内去重后的远端查询设上限(如 20),超出的项给 warning | +| S-7 | 文件管理的重活仍在 API 进程的 HTTP 请求内做:跨卷进回收站最多复制 2 GiB;还原时跨卷 `copyTree` **没有大小上限**;永久删除时递归 `rm`;所有 fs 调用都不经 fsproxy,挂死的挂载会占满 API 进程的 libuv 线程池,而 `probeRoot` 的 2 秒超时只挡住等待,挡不住卡住的系统调用 | `file-manager/trash.ts:390-399,513-516` | 还原沿用 `trashCrossMaxBytes`,或者跨卷的回收站/还原改为入队任务、返回 taskId;根探测与 readdir 走 FsProxy 的快操作。暂时做不到就写进已知限制(已列入 §8) | +| S-8 | 完成判定有两条路径。`organize.ts` 仍保留本地的 `slotComplete`,并直接把 once 订阅置为 `completed/slot_satisfied`;`services/subscriptions.ts` 按总集数账本判定并写 `subscription_history`。两者结论不同时,once 订阅可能不写历史就完成 | `acquisition-organize/services/organize.ts:1458-1492,1548` | 删掉本地判定,统一调用 `services/subscriptions.ts`(05 §4 遗留里已经点名) | +| S-9 | 文档与代码不同步 | 见右列 | `moviepilot-rust-transfer.md` §7 遗留第 1 条("编译二进制回退 in-process")已被 3641ab9 解决,改为已做;contracts README 缺 `files.ts`、`redact.ts`、`browser.ts`、`auth.ts`;adapters README 的 fs 行缺 `FsProxy` 和 `./fsworker` 导出;`apps/server/README.md` 的模块清单缺 `file-manager/`,角色缺 `worker:agent` 和 `fsworker`;04 表 S9 行没有标状态 | +| S-10 | 验收开关 `UMT_FS_TEST_HANG_AFTER_BYTES` 在生产二进制里同样生效。误设以后,所有数据类文件操作都会卡死,直到被 SIGKILL | `packages/adapters/src/fs/fsproxy.ts` | 只在 `NODE_ENV!=="production"`,或者同时设置 `UMT_FS_TEST_HOOKS=1` 时生效;preflight 发现已设置就 warn | + +### 1.3 建议 + +- `POST /files/probe` 标的是 `organize.read`,但会写 `media_asset.media_info`,而且在请求里串行跑最多 20 次 ffprobe,每次 30 秒超时。建议改成 `organize.execute` 或入队。 +- 永久删除只在单项时要求 `confirmName`,多项时没有服务端确认(`trash.ts:217`)。 +- 测试可以补几处:trash 用例在审批消费后补一条"重放 → 403";download 用例补"admin scope 的 API Key → 403";E6 自动恢复现在只核实到 recheck 后回到 `downloading`,可以再延伸到入库;engine-retry 用的是 mock 的 enqueue,可以加一条真实引擎的用例。 +- e2e 文件编号重复(`e6-auto-recover` 和 `e6-w4a-endpoints`),建议改成 E10。迁移 0017 空缺维持现状即可(已知、无害)。 +- 04 §1.3 的 7 条建议都还没动(下载状态机 `ALLOWED=ALL`、`scanIncremental` 出错仍返回 complete、流程版本升级没有测试、AT18 e2e、AT42 未纳管、vertical 用例依赖执行顺序、web 单测少),已并入 §8。 +- 清掉 biome 的 1 个 warning 和 1 个 info。 + +## 2. 第一轮问题复核 + +| # | 第一轮问题 | 结论 | 证据 | +|---|---|---|---| +| R1 | 后台循环绕过引擎;HTTP 内同步重活 | 已验证 | fe9693d:`index.ts planTick` 只调 `enqueueTracked`/`enqueueSweep`,W5-C 新增的 `sweepDueSlots`/`sweepSubscriptionSearch`/`sweepMetadataRefresh` 也只 enqueue;`routes/downloads.ts:100-125` 返回 202 `{taskId,runId}`;`__tests__/background-loop.test.ts` | +| R2 | 版本 0.0.0 | 已验证(tag 待打) | 根与 9 个成员 `deno.json`、`apps/web/package.json` 都是 0.1.0;实机 `/health` 返回 `0.1.0`;`git tag` 为空,由协调者打 | +| R3 | 授权内恢复 | 已验证 | 86af723;`e6-auto-recover` 3 条:无授权降级、已发布策略授权下 `task.inspect→download.recheck`→系统核实→事项 resolved、换源被拒、重复触发只有一个 run、预算超限。3641ab9 把 `autoInvestigateMode` 放进了设置页 | +| R4 | `sourcePaths` 无约束 | 已验证 | ceff9cf;`organize-guard.test.ts` 6 条:根外、`..`、符号链接 → 400,dryRun 同样受限;API Key 做 Move 没有授权 → 403;grant 绑定的策略非 published 不算;审批用后 consumed;H&R → 409 | +| S1 | 自己批准审批 | 已验证 | `api.ts` 中 approvals 两个端点为 `auth:"session"`;`approvals.test`「admin scope 的 Key 同样 403」;实机 DEV key → 403 | +| S2 | logger 脱敏窄 | 部分:logger 与模型上下文已验证,rss/browser 仍缺 | `contracts/redact.ts` 由 logger 与 agent 共用,`logger.test` 通过;rss/browser 见 S-4 | +| S3 | TMDB 错误带 api_key | 已验证 | f48e1aa;`tmdb/client.ts` 的 message 经过 `redactUrl` | +| S4 | 审批分支无测试 | 已验证 | `test/agent/approvals.test.ts` 5 条,断言了"改计划→被拒→重批→只消费一次→不可重放" | +| S5 | 剧集纵切 | 已验证 | `e7-tv-series`;W5-C 又加了 `e9-continuous-series` | +| S6 | AT24 | 已验证 | `e8-tag-removal` 4 条,用 qB 调用日志断言:`torrents/delete` 计数不变、资产与位置不变 | +| S7 | 生产构建能开 Agent mock | 已验证 | `agent-mock.test.ts`;文件页 mock 也按同一套规则只在 DEV 生效(`resolveFilesMock`,`model.test.ts:237`) | +| S8 | 引擎面板重跑 | 已验证 | `engine-retry.test.ts` 2 条(幂等键序号、审计、409/404/403) | +| S9 | 控制台 TOTP 入口 | **仍缺** | 见 S-3 | +| S10 | 注册表鉴权标注 | 已验证,但新端点回归 | `api-registry.test` 4 条通过;新增的 download/purge 没有标注,见 S-2 | +| S11 | 文档不一致 | 已验证,但有新的不同步 | 见 S-9 | + +## 3. §6 追加范围 + +### 3.1 订阅增强(对照 05) + +| 05 §3 条目 | 状态 | 证据 | 缺口 | +|---|---|---|---| +| 1 总集数来源与账本 | 完成 | 迁移 0025(`content_unit.episode_total*`);`sources-identity/service/metadata-totals.ts`;`subscription-search.test` 断言季总集数写入、pending→active | 只建季单元,不预建集单元(设计如此) | +| 2 `resolveEpisodeTotal` | 完成 | `domain/slot/episode-total.ts`;`domain/test/subscription.test.ts` 覆盖账本 §13 的样本 1、9 | — | +| 3 完成判定 | 完成(有双轨) | `services/subscriptions.ts slotComplete`;`__tests__/subscription.test.ts:233`「总集数未知不结束、记疑似缺集;就绪后补齐 → once 完成并写历史」 | S-8 | +| 4 `computeUnitGaps` | 完成 | `domain/slot/gaps.ts` + 单测;`services/acquisition.ts` | — | +| 5 Slot×单元预留 | 完成 | 0025 去掉 `acquisition_plan_slot_active_key`;`domain/slot/inflight.ts`;`subscription.test.ts:112`(20 个断言);E9「Slot 忙时 E02/E03 同时在途」 | 电影 Slot 仍然只允许一个在途计划(设计如此) | +| 6/7 忙后重评、主动搜索 | 完成 | `slot_deferral`、`sweepDueSlots`;`searchSubscription` 与 `POST /subscriptions/{id}/search`(admin,入队) | — | +| 8 元数据 6h 刷新 | 部分 | `sweepMetadataRefresh` | 完成前没有强制刷新 | +| P1 9–14 | 完成 | include/exclude 正则白名单、站点范围、每集体积、集范围、季包选文件、`slot_unit` 分集洗版 | — | +| P1 15–17、P2 18/21–23/25/26 | 未做或部分 | 05 §4 遗留 | 见 §8 | + +REQ 增强点:"总集数未知不结束"和"临时集单元"从 ✗/◐ 变成 ✅;"Slot 多目标"(`require_distinct_asset` 没有接入)、"偏好标签参与评估"、"Agent 选片理由与独立预算"、"回退等待期"、"队列按优先级出队"仍是 ✗/◐。 + +### 3.2 文件管理(对照 06) + +**端点**:规格里的 16 个全部实现,另外新增 `POST /files/copy` 和 `GET /files/records`。与规格的偏离: + +| 端点 | 与规格的偏离 | 性质 | +|---|---|---| +| download | 规格写的是 `organize.read`,实现为仅管理员会话 | 更严,可以接受 | +| 永久删除开关 | 规格写的是设置项 `files.allowPermanentDelete`,实现为环境变量 `FILES_ALLOW_PERMANENT_DELETE` | 需要重启才能生效,可以接受 | +| scrape | `writeNfo/images` 返回 `not_implemented` | 规格允许拆到 W5 之后,已列入 §8 | +| 路径守卫 | 没有下沉到 `adapters/fs/guard.ts`,模块自带 `resolveIn/recheck` | runbook 已写明 | + +**§4.1 不变量**: + +| 不变量 | 状态 | 代码 | 测试 | +|---|---|---|---| +| 1 realpath 在根内;`..`、符号链接逃逸、根本身写操作拒绝;执行前再 realpath(TOCTOU) | 满足 | `common.ts resolveIn/recheck`;所有写端点都先过 `resolveIn`(mkdir/rename/move/copy/organize/delete/restore/probe/identify/download) | `browse.test:90`(`..`、URL 编码、根外、符号链接指向文件和目录、未登记根);`write.test:280` 的 TOCTOU 用例:预览后源被换成链接 → `path_escape`,根外文件不动 | +| 2 浏览器不直接写;审计与幂等键 | 满足 | `routes.ts idem()` + `ledger.ts`;每个写操作都调 `audit()` | `write.test:196`「幂等重放不重复执行」 | +| 3 路径变更只来自显式操作,同一事务更新位置与绑定 | 满足 | `mutate.ts`、`records.ts relocate` | `write.test:74/144` | +| 4 删除不碰 qB;做种中不能改名、删除、Move | 满足 | `trash.ts checkItem`、`transfer.ts download_active` | `trash.test:38`、`write.test:174` | +| 5 失败≠不存在 | 满足 | `onlineRoot` 返回 503;`fsErrorToFiles` | `browse.test:120`、`trash.test:202`、`w5a2.test:392` | +| 6 只执行 dryRun 产出的 planId,源事实不变 | 满足(move/copy/organize/delete) | `loadPlan` + 指纹 | `write.test:196`、`identify-organize.test:72` | +| 7 不覆盖 | 满足 | `moveSafe`;copy 遇到目标已存在记失败 | `write.test:74`、`w5a2.test:182` | + +**前端**:路由 `/console/files` 和 `/console/files/trash`,组件 `DirTree`、`PathBreadcrumb`、`ContextMenu`、`DetailSheet`,4 步向导(未决项挡住下一步)。虚拟滚动是自己写的(没有引入 `@tanstack/react-virtual`,所以不需要登记许可)。键盘支持 `role="grid"`、F2、Del、Backspace、Shift 区间选、Ctrl+A;拖拽后先打开 dryRun 对话框;SSE `files.changed` 触发刷新。测试:`components/files/model.test.ts` 16 条(选择模型、徽标、禁用原因、集号偏移、未决判定、mock 开关),冒烟覆盖两个路由。 +**缺口**:规格 §4.2 要求的前端 e2e("下载根散文件 → 识别 → 向导 Copy → 整理记录 → 状态变为已登记")和移动端快照没有做。 + +### 3.3 Agent 会话页(对照 agent-ui-reference §3 映射表) + +| 事件/对象 | 落地 | 位置 | +|---|---|---| +| `run_started` / `text_delta` | `Message` + `StreamingResponse`(增量拼接,加流式光标) | `agent-conversation.tsx` | +| `thinking_delta` | 自写可展开的思考块,保留完整文本 | `:239` | +| `tool_call_start/end` | `ToolResult`,带类别/scope/风险/幂等徽章 | `agentui/tool-result.tsx` | +| `tool_call_rejected` 与授权失败(`unauthorized/forbidden`) | `ToolApproval` 的 `rejected` 醒目态(`role=alert`) | `:130-139` | +| `compaction` | `MessageMarker` | `:451` | +| `run_completed/failed`、超预算 | `MessageBubble` 的 warning/danger 变体 + 超限原因 | — | +| Approval | `ApprovalCard`,状态集对齐 pending/approved/rejected/revoked/expired/consumed;非管理员会话时按钮禁用 | `:626-670` | +| `AgentGoalReport` 声明与核实分开 | `FinalReport`,双栏 + `FileDiff` + `CitationList` | `:684-790` | +| Budget | `BudgetBar`(自写,有超限态) | `:563` | +| RunMode | 头部显示"只要方案 / 授权内执行";plan_only 用 `TodoList` | `:338,491-514` | +| 滚动跟随 | `MessageScroller`(阈值状态机 + "回到底部"按钮) | `agentui/message-scroller.tsx` | + +结论:映射表每一行都落地了,许可文件 `LICENSE-agentui.md` 也在。缺口:组件层没有单测,只有 `agent-helpers.test.ts`;幂等键取自审计,最多翻 3 页(文档已写明)。 + +### 3.4 转移语义(对照 moviepilot-rust-transfer §7) + +| 项 | 状态 | 证据 | +|---|---|---| +| 挂死隔离在**生产二进制**里生效 | 已验证 | 3641ab9:`main.ts` 在加载其余模块前分派 `--role fsworker`;`deno.json compile --allow-run=…,/app/umt-server`;`deploy.md` W5-E 容器演练:日志里 `workerKind:"self"`,`/proc` 里能看到 `umt-server --role fsworker`(uid 10001),停滞 3 秒 → SIGTERM → SIGKILL,共 3 次,之后账本记 `TIMEOUT` 并建待处理,只残留 `.uhd-partial`、没有最终名的半成品;`fsproxy.test` 的 10 条中有 3 条挂死用例(屏蔽 SIGTERM、FIFO、快操作) | +| partial 清理 | 已验证 | `copy.ts sweepPartialsOlderThanMs`,`executeRecord` 对每个目标目录也清一次;`organize-fs.test:48`「写前清走超过 24h 的 partial」 | +| replace | 已验证 | `copy.ts replaceCommit`(硬链接留底 → rename 原子替换);3641ab9 起旧文件进 `.umt-trash///` 并登记 `trash_item`;`organize-fs.test:48` | +| EEXIST 误报 | 已验证 | 对账阶段比对失败按 IO/TIMEOUT 处理,不再报 EEXIST;`organize-fs.test:118`:FIFO 让对账挂死 → 2 次 TIMEOUT 重试 → 第 3 次 failed + `fs_retry_exhausted` | +| TIMEOUT 错误码 | 已验证 | `FsErrorCode.TIMEOUT`,`ETIMEDOUT` 也映射为它;`fsErrorClass` 归为 retry;`fsproxy.test:184` | +| 缺口 | — | `planImport` 的 stat/哈希、`reconcile.ts`、文件管理都不经 fsproxy;没有根级熔断(把根标为 suspect);基准 B1–B3 没跑;失效软链、hardlink、蓝光原盘、organize 的空目录上溯清理 | + +### 3.5 差分补迁(对照 mp-rust-logic-parity §10) + +| 链路 | 补迁前 | 补迁后 | missing / our_bug | +|---|---|---|---| +| 标题识别(216 条 × 18 字段) | 92.9%;剔除登记项后 97.4% | 94.4%;剔除登记项后 100% | 0 / 0 | +| 显式 ID | 8/11 | 10/11 | 0 / 0 | +| 过滤规则(644 个 case) | 95.7% | 97.8% | 7(每集体积,属 Slot 能力)/ 0 | +| 站点索引解析 | 98.1% | 99.5% | 0 / 0 | +| 繁简转换 | 84.1% | 92.8%;剔除登记项后 100% | 0 / 0 | +| RSS | 95.5% | 未变 | 0 / 0 | + +差分测试在 `adapters/test/differential/{indexer,rss}.test.ts` 和 `domain/test/differential/{meta,zhconv}.test.ts`,本轮单包测试全部通过。遗留:字幕搜索还没接到站点适配器;RSS 不带时区的时间按站点时区解释;HTML 命名实体。 + +## 4. 解耦审计(03)复核 + +| 项 | 状态 | 复核 | +|---|---|---| +| 后台循环只编排 | 满足 | `planTick` 只有 enqueue;qB 同步有独立定时器;文件管理的 `purgeTrash` 也是入队(`file-manager/index.ts enqueuePurge`) | +| HTTP 内无重活 | 部分 | cleanup/reconcile 返回 202;`/subscriptions/{id}/search` 入队;files 的 move/copy/organize 执行入队 `verifyAndImport`,请求内只等待,最多 20 秒。**遗留**:`GET /inventory/media-servers` 串行查 Emby;files 的回收站/还原跨卷复制、永久删除、identify、probe 仍在请求内(S-6、S-7) | +| worker 分组 | 满足 | `--role worker`(io 组)、`--role worker:agent`、`--role all`;认领时按任务名过滤(`worker-groups.test`);`purgeTrash` 注册在 io 组 | +| 超时与限并发 | 部分 | 4 个外部 IO 能力在 HTTP 路径上有 15 秒超时,站点并发 2、TMDB 并发 4。**`/files/identify` 绕开了限流器**(S-6);超时后底层调用仍会跑完 | +| 探针 | 满足 | 事件循环延迟探针,1 秒采样,偏差超过 5 秒告警,随心跳上报;fsproxy 的 `stats()` 写进 `organize.transfer` 日志 | + +## 5. 安全复查 + +### 5.1 新端点的 scope 与会话要求 + +| 端点 | 注册表 scope | 处理器内的附加要求 | +|---|---|---| +| `GET /files/roots`、`/files`、`/files/stat`、`/files/trash`、`/files/records`;`POST /files/identify`、`/files/probe` | organize.read | 无(probe 会写 `media_info`,见 §1.3) | +| `POST /files/mkdir`、`/files/rename`、`/files/copy`、`/files/scrape`、`/files/trash/restore` | organize.execute | 目标或源根必须可写,或者是 managed 库根 | +| `POST /files/move`、`/files/organize`(action=move) | organize.execute | API Key 需要 grant 或一次性审批(`authorizeMove`);move 只核对第一个源的作品(S-1) | +| `POST /files/delete` | organize.execute | 进回收站:API Key 每次都要审批(绑定 planId 与内容哈希,用后 consumed);永久删除:必须设置 `FILES_ALLOW_PERMANENT_DELETE`,且只接受管理员会话 | +| `POST /files/trash/purge` | admin | 只接受管理员会话;注册表没有标 `auth:"session"`(S-2) | +| `GET /files/download` | admin | 只接受管理员会话,文件类型白名单,上限 20 MiB;注册表没有标 `auth:"session"`(S-2) | +| `POST /subscriptions/{id}/search`、`/subscriptions/{id}/episode-total` | admin | 无 | +| `GET /subscriptions/{id}/progress`、`/subscription-history` | read | 无 | +| `POST /engine/runs/{id}/retry` | admin | 只允许已结束的 run;幂等键带序号;写审计 | +| `POST /agent/runs`、`/agent/runs/{id}/messages`、`/agent/runs/{id}/abort` | organize.execute | 能力调用在服务端逐次授权 | +| `POST /approvals`、`PATCH /approvals/{id}` | admin + `auth:"session"` | API Key 和 DEV key 一律 403(已实测) | + +注册表共 208 个端点:read 89(其中 3 个 public)、organize.read 10、organize.execute 27、admin 79(其中 5 个 session)。 + +### 5.2 其他项 + +- **路径守卫覆盖**:13 个 files 写端点和读端点都先过 `resolveIn`,执行前再调 `recheck`。只有 restore 的目标可写性,以及原位还原时的递归 mkdir 这两处有缺口(S-5)。 +- **回收站与永久删除**:`purgeTrash` 删除前先复核路径在 `.umt-trash` 或 `DATA_DIR/trash` 下,并且先删行、再删文件。永久删除有双闸(环境变量开关 + 管理员会话)。restore 不需要审批(只把文件放回,风险低,可以接受)。 +- **下载上限**:打开文件后按文件句柄 stat,超过 `FILES_DOWNLOAD_MAX_BYTES` 返回 413;SVG 一律作为附件下载;响应带 `nosniff` 和 `CSP sandbox`。`w5a2.test:350` 覆盖了上述行为。 +- **审批只认管理员会话**:创建和决定两个端点都是 `auth:"session"`。`consumeApproval` 与 `authorizeMove` 都用"`status=approved` 才更新"来保证只消费一次。 + +### 5.3 脱敏键表四处对照 + +| 参数 | contracts(logger + redactForModel) | rss/redact.ts | browser/redact.ts | +|---|---|---|---| +| passkey / authkey / torrent_pass / rsskey / downhash / apikey / api_key / access_token / token / secret / sign / credential / key | ✓ | ✓ | ✓ | +| `pass_key` / `auth_key` 等带分隔符的写法 | ✓ | ✗ | ✗ | +| c_secure_pass(作为 query 参数) | ✓ | ✗ | ✗(只遮 cookie 里的 `c_secure_*`) | +| refresh_token / password / uid_key | ✓ | ✗ | ✗ | +| auth | ✓ | ✓ | ✗ | +| signature | ✓ | ✗ | ✓ | +| 请求头、Bearer、URL userinfo | ✓ | 只有 userinfo | cookie 头、userinfo | + +结论:logger 与模型上下文共用同一份实现;rss 和 browser 仍各自维护(S-4)。 + +## 6. 工程规范 + +| 项 | 结论 | +|---|---| +| 提交信息 | 82 个提交全部合规,0 个违规 | +| README、runbook 与 deno 任务 | 文档里出现的 19 个 `deno task` 全部存在(根目录或成员包);没有残留 pnpm/npm 用法 | +| 包 README 与导出 | adapters 新增的 `./fsworker` 导出、contracts 新增的 4 个文件、server 的 file-manager 模块没有写进 README(S-9) | +| CHANGELOG | 没有覆盖 W4-E4 到 W5(B1) | +| 迁移与生成类型 | 0024 `trash_item`、0025 订阅单元、0026 整理动作,编号连续(0017 空缺是历史遗留);`db/src/types.ts` 已含 `trash_item`、`slot_unit`、`slot_deferral`、`subscription_history`、`episode_total*`,以及 action/mode 的 `trash/delete/restore` | +| api.ts 双向登记测试 | `api-registry.test` 4 条通过("OpenAPI 路由都已登记"、"登记的都已实现"、public 端点集合、session 端点集合);漏标见 S-2 | + +## 7. 抽查 12 个新增测试 + +| 用例 | 结论 | +|---|---| +| `write.test`「TOCTOU:预览后源被换成指向根外的链接」 | 有效:断言 400 `path_escape`、根外文件内容不变、目标不存在 | +| `trash.test`「永久删除默认关闭;API Key 删除需一次性审批」 | 有效:断言 403 `permanent_disabled`、无审批时 `approval_required`、审批后执行成功且文件已删除。缺一条"重放 → 403" | +| `w5a2.test`「下载:仅管理员会话……」 | 有效:断言 MIME、`disposition`、`nosniff`、READ/EXEC key 403、视频 403、超限 413、`path_escape`、目录 400、审计 2 条。缺"admin scope 的 key" | +| `browse.test`「根离线 → 503,不返回空列表」 | 有效(按用例名与 `root_unavailable` 断言判断) | +| `organize-fs.test`「对账挂死 → TIMEOUT(不是 EEXIST)→ 有限重试 → 待处理」 | 有效:逐次断言错误信息、文件状态保持 running、最终 failed 且 `code=TIMEOUT`、decision 里 `attempts=3` | +| `fsproxy.test`「复制中途挂死 → SIGKILL → TIMEOUT」 | 有效:断言错误类别为 retry、耗时下限、进度大于 0、`timeouts/killed=1`、最终名不存在、partial 可被清理 | +| `subscription.test`「Slot×单元多在途……移除释放」 | 有效:20 个断言(在途单元、延后、释放、重评) | +| `e9`「Slot 忙时 E02/E03 同时在途,另一版本延后」 | 有效(E9 三步串联,最后断言入库和历史) | +| `e6-auto-recover`「授权内 recheck → 系统核实」 | 有效:断言 run 字段、调用序列 `[task.inspect, download.recheck]`、`download__submit` 被拒、`grant_id`、qB recheck 只 1 次、decision resolved、去重。只核实到 downloading | +| `e8`「qB 任务消失 → removed;资产不变,不重下」 | 有效:断言位置前后相同、Slot 仍 satisfied、计划只有 1 个、`torrents/add` 计数 | +| `approvals.test`「改计划 → 被拒 → 重批 → 只消费一次 → 不可重放」 | 有效 | +| `engine-retry.test`「按原任务名与输入重新入队,序号递增,写审计」 | 部分有效:enqueue 是 mock,没有验证真实引擎里的新 run | + +## 8. v0.1 已知限制(供 CHANGELOG 引用) + +**范围外(00 §3.2 / 02 后置)** +1. 国产综艺分段、`require_distinct_asset` 组合校验、浏览器/camofox 与原站视图、HHClub/CHD、旧库分范围接管、Agent 主观选片与规则学习、多用户。 +2. Passkey、主密钥轮换、子代理、Transmission、TTG 自定义适配、馒头 adult 模式。 + +**订阅(05 遗留)** +3. 完成判定前不会强制刷新元数据;titleQuery 订阅不会自动建档回填;季范围语义(`current_season` 在没有季单元时固定)未做;偏好标签不参与评估。 +4. 同作品同季重复订阅没有查重;`prefer_pack`、回退等待期、Agent 选片理由与独立预算、队列按优先级出队、实体级识别词都未做;没有"清账重置"(只能把状态改回 active 重新订阅)。 +5. 完成判定暂时有两条路径(S-8)。 + +**文件管理** +6. NFO/图片投影(scrape 的 `writeNfo/images`)、硬链接、蓝光原盘整目录操作未做;路径守卫没有下沉到 adapters。 +7. 从回收站还原不会恢复资产状态,需要重新整理登记;永久删除开关是环境变量,不是设置项。 +8. 跨卷进回收站(≤2 GiB)、跨卷还原、永久删除、identify(每项超时 15 秒,不经 TMDB 限流器)、probe 都在 HTTP 请求内执行,也都不经 fsproxy(S-6、S-7)。 +9. 前端没有向导 e2e 和移动端快照,页面行为靠 model 单测和冒烟覆盖。 + +**转移与文件系统** +10. `planImport` 规划期的 stat/哈希、资产对账和文件管理不经 fsproxy;没有根级熔断;失效软链一律按冲突处理;hardlink、organize 源侧空目录上溯清理、蓝光原盘未做。 +11. 二进制必须位于 `/app/umt-server`,否则 fsproxy 回退为进程内看门狗(能按时报 TIMEOUT,但卡住的线程无法回收)。基准 B1(50 GB)、B2(并发)、B3(真实 FUSE 冻结)没有跑。 + +**Agent 与安全** +12. 控制台没有 TOTP 开关入口(S-3)。 +13. rss 和 browser 的脱敏键表没有与 contracts 统一(S-4)。 +14. `GET /inventory/media-servers` 在请求内串行查 Emby;能力调用超时后,底层调用仍会跑完。 +15. Agent 页的幂等键来自审计(最多 3 页),审批卡片的 planId 靠解析工具摘要得到,较早的运行可能取不到。 +16. 自动恢复只核实到下载恢复,入库由后续流程负责;恢复白名单只有 `task.retry`、`download.recheck`、`identity.resolve`。 + +**识别与站点** +17. 字幕搜索只到解析层;RSS 不带时区的时间与 HTML 命名实体未处理;过滤器的 tmdb 放行、SPECSUB 等属于 Slot 能力扩展。 + +**质量与运维** +18. 恢复流程只有人工演练;流程版本升级没有产品侧测试;下载状态机 `ALLOWED` 未收紧;`scanIncremental` 出错时仍返回 complete;AT18 恶意标题 e2e、AT42 未纳管占用没有测试;`vertical.test` 的用例依赖执行顺序;web 单测少。 +19. `deno task ci` 约 135 秒以上,server/tasks 测试是主要耗时。 + +## 9. 给 M2 的前 5 条建议 + +1. **把 IO 统一收到 fsproxy 并加根级熔断**:`planImport`、reconcile、文件管理的 stat/readdir/复制全部经 FsProxy;根超时标为 suspect,暂停向它派发,建待处理;回收站和还原的跨卷复制改为入队。跑 B1–B3 基准,并把结果写进性能基线。 +2. **凭证与授权收口**:只保留一份脱敏实现(rss/browser 改用 contracts)并加全仓凭证泄漏的故障注入测试;注册表的 `auth` 标注由处理器的真实行为自动核对(对每个端点用 admin scope 的 key 发一次请求,断言 403 与标注一致);多作品 Move 按作品集合授权;补 TOTP 入口。 +3. **订阅闭环第二阶段**:消除双轨完成判定;完成前强制刷新;偏好标签编译进 SlotSpec;`require_distinct_asset` 接入评估与入库(这是综艺和双版本的前置);队列按优先级出队。 +4. **文件管理补全**:NFO 投影(带 umt 标记与字段锁)、hardlink(`link` 自带 no-clobber)、蓝光原盘目录、空目录上溯清理;前端补向导 e2e、移动端快照和键盘可访问性测试。 +5. **把 CI 当作发布门禁**:e2e 按包并行、给 server 测试分片,把 ci 控制在 90 秒内;夜间跑"备份 → 清库 → 恢复 → 只读对账 → 映射比对"和流程版本升级回归;CHANGELOG 与已知限制由每个波次的汇报强制更新,避免再出现 B1。