fix(desktop): 安装与打包 .cindy 时保留 Unix 执行位 - #2948
Conversation
.cindy 里声明为 0755 的普通文件在 macOS / Linux 安装后变成 0644,插件 无法启动随包的本机可执行程序。文件内容与 SHA-256 一致,只有 mode 丢了。 链路上有三处丢失点: - extractToStaging() 用 writeFile 落盘,从不读 JSZip 的 unixPermissions; - forge / export 打包时不写 unixPermissions,且 JSZip 缺省按 DOS 平台生成 version made by,导致 Cindy 自己打出的 .cindy 压根不带 Unix mode; - 签名 / 审核会重新生成 central directory,把打包侧刚写入的执行位抹掉。 新增 ghostZipPermissions.ts 作为三侧共用的唯一归一化入口:只采纳归档声明 的权限位(不按扩展名或 bin/ 目录猜执行位),剥除 setuid / setgid / sticky, 额外保底 owner 可读写以免恶意 mode-000 条目卡住后续升级卸载,缺少 Unix 元数据的旧 DOS 包完全不 chmod,Windows 直接跳过。 写盘后用 chmod 而非 writeFile 的 mode 参数:后者会被 umask 掩掉,umask 077 时 0755 会变成 0700,拿不到确定性的权限恢复。 zip-slip、路径穿越、符号链接拒绝与 zip bomb 上限均未改动。存量已装插件不做 权限回补,由重装或升级插件自然恢复。 Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbb612a66c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
| Filename | Overview |
|---|---|
| apps/desktop/src/main/cindy-brain/ghostContentTree.ts | 导出了统一稳定态判据供权限快照复验,但该判据仍未直接比较 mode。 |
| apps/desktop/src/main/cindy-brain/exportGhostPackage.ts | 导出现在保存 Unix mode 并复验文件状态,但 ctime 未变化时仍可能组合不同时刻的 mode 与内容。 |
| apps/desktop/src/main/cindy-brain/GhostManager.ts | 安装侧在写入普通文件后恢复经过钳制的归档权限,并在 Windows 或缺少 Unix 元数据时跳过 chmod。 |
| apps/desktop/src/main/cindy-brain/ghostZipPermissions.ts | 集中实现安装、归档和重打包所需的 Unix 权限解析、类型校验与安全归一化。 |
| apps/desktop/src/main/cindy-brain/ghostSignature.ts | 签名和审核重打包改为 UNIX 平台输出,并为每个条目保留安全化后的权限。 |
| apps/desktop/src/main/cindy-brain/forge.ts | Forge 打包读取文件快照中的 mode,并将普通权限写入 UNIX ZIP external attributes。 |
Sequence Diagram
sequenceDiagram
participant FS as 插件文件
participant Export as exportGhostPackage
participant Stable as sameStableFileState
participant ZIP as .cindy 归档
Export->>FS: stat(取得 mode)
Export->>FS: readFile(取得内容)
Export->>FS: stat(读后复验)
Export->>Stable: 比较身份、size、mtimeNs、ctimeNs
Note over Stable: 当前未直接比较 mode
Stable-->>Export: 稳定
Export->>ZIP: 写入内容与读前 mode
Comments Outside Diff (1)
-
apps/desktop/src/main/cindy-brain/ghostContentTree.ts, line 193-197 (link)如果底层文件系统的时间戳精度不足或返回陈旧的
ctimeNs,读取期间的chmod不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。Context Used: 使用和PR描述相同的语言进行评论 (source)
Prompt To Fix With AI
This is a comment left during a code review. Path: apps/desktop/src/main/cindy-brain/ghostContentTree.ts Line: 193-197 Comment: **稳定态判定遗漏 mode** 如果底层文件系统的时间戳精度不足或返回陈旧的 `ctimeNs`,读取期间的 `chmod` 不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。 **Context Used:** 使用和PR描述相同的语言进行评论 ([source](https://app.greptile.com/review/custom-context?memory=instruction-0)) --- For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Prompt To Fix All With AI
### Issue 1
apps/desktop/src/main/cindy-brain/ghostContentTree.ts:193-197
**稳定态判定遗漏 mode**
如果底层文件系统的时间戳精度不足或返回陈旧的 `ctimeNs`,读取期间的 `chmod` 不会被当前判据识别,已签名导出便会把读取前的 mode 与随后读取的内容写入同一归档条目,导致执行位错误且不会触发整体重试。
```suggestion
return after.isFile() &&
sameFileIdentity(before, after) &&
before.size === after.size &&
before.mode === after.mode &&
before.mtimeNs === after.mtimeNs &&
before.ctimeNs === after.ctimeNs;
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (4): Last reviewed commit: "fix(desktop): 签名包同样恢复归档 mode,并写明该取舍的依据" | Re-trigger Greptile
回应 review 的两处 P1。 装入侧钳掉 group/other 写位:原实现会原样采纳 0777 / 0666,于是被篡改的 包、甚至源文件权限没设好的正常包,都能把 group/world 可写文件装进插件内容 目录,同机其它账号即可改写受害用户随后会执行的插件代码。改前此处恒为 0644, 不存在这个面。0755 / 0644 / 0700 不受影响,0777 收敛为 0755、0666 为 0644。 导出已签名插件时改为同一文件句柄取 stat 与内容:原先 stat 与 readFile 是两 次独立调用,并发 chmod 落在中间会把改动前的 mode 与改动后的内容写进同一归档 条目。内容与 SHA-256 仍然自洽,校验发现不了,导出包的执行位却是旧值。 签名 statement 格式未改动:mode 仍不在签名覆盖范围内,残留风险已在 PR 描述中 向放行人显式申明。 Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
|
⏸️ 本 PR 触发 pluginBase 维护者确认门。 修改了插件安装与打包时的文件权限处理(保留 Unix 执行位),属插件基座变更,需维护者在 PR 上 Approve 后方可合并。 讨论 issue 已创建,详见上方链接。 讨论 issue:#2956 |
|
收到,这道 pluginBase 门是预期的 —— 我在 PR 描述末尾也预先标注了本改动命中插件基座、需放行人明确 Approve,不因「是 bugfix」豁免。 issue 里的两个确认项已逐条答复并给了代码依据:#2956 (comment) 摘要:
另外请一并看 PR 描述里的「残留风险」小节:review 阶段 Codex 指出 mode 不在签名覆盖范围内,我已钳掉 group/other 写位堵掉最实际的本机提权路径,但把 mode 绑进签名 statement 属包格式改动(需 v2 + 兼容存量 v1 + 流水线重签),已拆出本 PR。如果你们认为那条残留必须先堵,请 Request Changes,本 PR 可以等那个改动落地后再合。 CI 11 项全绿,含 Windows 两个分片实机通过。等你们判断,我不自行推进。 |
回应维护者确认门(makecindy#2956)的三条。 已签名 / 已审核包不再采纳归档声明的 mode。签名 statement 只覆盖 (path, sha256, bytes),mode 不在其中,恢复它等于把未认证的 central-directory 元数据当成已签名事实 —— 能篡改包字节的人可以在验签仍然通过的前提下翻转执行位。 未签名包不存在越过签名边界的问题,照常按声明恢复。把归一化 mode 签进 statement (schemaVersion 2)另行跟进,本次不动包格式。 导出已签名插件时在读取后复验文件稳定态。同一句柄只挡住 open 与 stat 之间的 窗口,chmod 落在读取过程中仍能造出「读前的 mode + 读后的字节」。复用 ghostContentTree 的 sameStableFileState 判据(含 ctime,故覆盖 chmod),不另造 一份:签名文件不一致即抛错,statement 条目不一致返回 null 交给既有整体重试。 Windows 门禁改成正面断言:原先只是在 win32 跳过 mode 断言,现按平台分叉 —— win32 断言 chmod 从未被调用,非 win32 断言确实调用过,避免这条路径以后静默 退化成「什么都没做」也能绿。 Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
|
维护者确认门(#2956)的三条已全部落地,推到
同时更正了 PR 描述里一处 overclaim:新包在旧客户端只保证结构兼容,旧客户端不具备执行位恢复能力,不宣称完整的反向功能兼容。 签名 statement 升 验证:desktop typecheck 干净;相关四个测试文件 335/335 通过。如实说明一处 —— |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09ac51a85c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
撤掉上一轮对已签名 / 已审核包关闭 mode 恢复的分支。那个分支让正式发布的
插件在 macOS / Linux 上依旧启动不了随包程序,只有未签名包受益 —— 等于对主
分发渠道没修。
mode 不在签名覆盖范围内这点不变(statement 只覆盖 path / sha256 / bytes),
但在 installedFileModeFromZip 的钳位之后,能篡改包字节的攻击者只剩「翻转
r / x 位」:内容改不了、文件增删不了(全量白名单 + 逐文件哈希 + canonical
全等)、特殊位剥掉、group/other 写位钳掉、owner 读写强制保留。去掉 +x 只是
他本来就能造成的可用性破坏(改坏一字节即验签失败);加上 +x 作用在他无法
选择内容的文件上,而执行流指向哪个文件由签名覆盖的 manifest 与插件自身代码
决定,拿不到代码执行。
该取舍连同失效条件("若日后有逻辑开始依赖 mode 做安全判断,须把归一化 mode
签进 statement")写进 extractToStaging 的注释,并由用例钉住:签名包与未签名
包走同一条恢复路径,0755 / 0644 保留、特殊位剥除。改成"签名包不采纳 mode"
会让该用例红 —— 这是产品/安全决定,不该被静默改掉。
上一轮的另外两项保留:导出读后复验文件稳定态、Windows 按平台正面断言。
Signed-off-by: zhanghengxd <128659069+zhanghengxd@users.noreply.github.com>
|
撤回的理由正是 Codex 复审指出的那条:那个分支形式上满足了「不把未认证 mode 当已签名事实」,但代价是正式发布的插件在 macOS / Linux 上依旧启动不了随包程序,只有未签名包受益 —— 对主分发渠道等于没修。用一个安全但功能为空的分支换掉 bug 修复本身不是好交易。 取而代之,把维护者确认门(#2956)点名要的那份依据补上了 —— 「现有证据不足以默认做出这一产品/安全决定」,那句我理解为「请给依据再判」。残留风险实算:钳位之后攻击者改不了任何文件内容、增删不了文件( 失效条件写进代码了,不只在 PR 描述里: 同时在 #2956 里给了一条更省的备选:真要认证 mode 也不必改签名格式 ——
上一轮另外两项保留不变:导出已签名插件时读后复验文件稳定态(复用 验证:desktop typecheck 干净;相关四个测试文件 335/335 通过。仍如实说明 —— |
MagicLizi
left a comment
There was a problem hiding this comment.
维护者确认(插件基座门)与审查结论:
存量插件兼容:确认无影响——指纹哈希不含 mode,批准状态/布局/包格式契约零改动,DOS 旧包逐字节原行为(spy 断言零 chmod),已装插件无需重装或重新确认。
安全取舍裁定(#2956 遗留):接受签名包与未签名包统一恢复归档 mode。前提已逐条核实:钳位后篡改者改不了内容、增删不了文件(buildStatement 全量白名单+逐文件 sha256)、设不了 setuid/setgid/sticky、装不出 group/world 可写文件;残留仅为原有 DoS + 在无法选择内容的文件上翻转 r/x 位,拿不到代码执行。失效条件已写进 extractToStaging 注释并有签名包用例钉住。manifest 声明式与 statement v2 作为备选已记录在讨论中,不阻塞本 PR。
审查:三处丢失点修复完整;导出读后复验(sameStableFileState 含 ctimeNs)关闭快照竞态;win32/非 win32 按平台分叉真断言;diff 与描述一致,无夹带。CI 11/11 全绿(含 Windows 分片),DCO 完整。没有 P0/P1。
|
维护者确认已通过(pluginBase / arch),PR 恢复正常推进。 |
|
合了。从三处丢失点的排查、把每条排除掉的怀疑都记录在案,到把「mode 要不要进签名边界」的风险实算清楚再交给放行人裁定——这轮迭代把该想的都想透了。以后 macOS/Linux 上装带本机程序的插件,终于不用用户手动 chmod 了,谢谢。 |
这次改了什么
摘要
.cindy包里声明为 Unix mode 0755 的普通文件,在 macOS / Linux 安装后变成 0644,插件因此无法启动随包的本机可执行程序。文件内容与 SHA-256 都一致,只有 mode 丢了。排查发现这条链路上有三处丢失点,任一处不修都会让执行位在实际使用中丢掉:
GhostManager.extractToStaging()用fs.promises.writeFile(dest, data)落盘,不传 mode,也从未读取 JSZip 的entry.unixPermissions,于是一律落成0666 & ~umask= 0644。forge.ts/exportGhostPackage.ts都是zip.file(rel, content)不带unixPermissions,且 JSZipgenerateAsync缺省按 DOS 平台写 version made by。也就是说ghost_forge_pack与导出产出的.cindy压根不带 Unix mode,只有外部zip/7z打的包才带得上。安装器单独修好,forge 打出来的插件依旧没有执行位。signGhostPackage/reviewGhostPackage会重新生成 ZIP central directory,不带platform: 'UNIX'就会把打包侧刚写入的执行位再抹掉一次——发布流水线会静默吃掉修复效果。新增
ghostZipPermissions.ts作为三侧共用的唯一归一化入口,避免解包、打包、重打包各写一份掩码。几条排除掉的怀疑,记录一下省得后人重查:不需要换 ZIP 库(JSZip 本来就解析 central directory 的 external file attributes,
zipEntry.js在versionMadeBy >> 8 === 3时给出unixPermissions);不是跨卷复制或原子替换丢的(staging → final 全程rename,同卷保留 mode);全新安装与覆盖升级共用同一个extractToStaging,所以是一处丢、修一处两条路径都好;内置插件 seed 路径用copyFile,mode 本来就保留,未改动。变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
.cindy中保存的 Unix 可执行位.cindy安装侧权限恢复;forge / export 打包侧写入 Unix mode;签名 / 审核重打包保留执行位;对应回归测试skillhub/installService.ts、importLocalSkill.ts、zipPacker.ts有同源的裸writeFile/ 不写 mode 问题,但按约定的范围本次不动,需另开 issue 跟进。chmod +xUI 变化
不涉及。
怎么验证的
自动验证
新增测试覆盖:
.cindyfixture 内同时放 0755、0644、4755 文件;manager.install(v1),断言mode & 0o777精确为 0755 / 0644,且 4755 落地为 0755、setuid 位为 0;manager.update(...)(不是二次 install),再次精确断言三种 mode,并确认内容已换成 v2 —— 覆盖「全新安装与覆盖升级结果一致」;unixPermissions === null)安装成功,且用 spy 证明完全没有调用chmod;JSZip.loadAsync后能读回 0755,且特殊位已剥除;手工验证
不涉及:本次改动的可观测点就是文件 mode,已由上述单测在真实文件系统上用
fs.stat精确断言,手工复核不会提供额外信息。未执行的验证
chmod(installedFileModeFromZip在win32直接返回 null),纯函数级有断言覆盖,实文件 mode 断言在win32上按平台跳过,避免假红或假绿。最终以 CI 矩阵为准。风险
风险分类
影响与回滚
影响范围:
.cindy的安装、打包、导出与签名重打包路径。存量插件影响:无需重装、无需重新确认权限、不丢凭证与偏好。 用户升级后什么都不做,已装、已批准、已启用的插件照旧可用——本 PR 只改「新落盘文件的 mode」和「新打包产物的 external attributes」,不碰批准状态 schema、指纹格式、manifest 校验、安装布局与包格式契约,旧 DOS
.cindy继续原样安装且完全不chmod。关于反向兼容的准确表述(更正初版措辞):新包在旧客户端只保证结构兼容,不保证功能兼容 —— 旧客户端可以正常解包与安装,但它压根不读
unixPermissions(这正是原 bug),因此不具备执行位恢复能力,随包本机程序在旧客户端安装后仍不可直接执行。不宣称完整的反向功能兼容。需要如实说明的一点:已经以 0644 装在盘上的插件,其执行位不会被自动回补,需重装或升级该插件才恢复。这不是本 PR 引入的退化(这些插件在修复前同样是坏的),也不影响插件的装入 / 批准 / 启用状态,属于「修复不追溯」而非「要求用户重新配置」。基于旧布局的升级用例跑在
GhostManager.test.ts的覆盖升级断言里。安全上的取舍(欢迎重点看这一段):
bin/目录猜执行位的逻辑,也不做统一chmod +x;& 0o777剥除 setuid / setgid / sticky,恶意包无法安装特权文件(有专门用例断言 4755 → 755);| 0o600保底 owner 可读写:否则恶意包里 mode 000 的条目会让后续升级 / 卸载变难。不影响验收语义(0o644 | 0o600 === 0o644,0o755 | 0o600 === 0o755);mkdir,zip 属性不影响目录权限,也不会作用到安装目录之外;safeJoin路径穿越 / 绝对路径检查、非规范条目路径判定、符号链接拒绝、zip bomb 的条目数与解压总量上限、__MACOSX/过滤全部原样保留。跨平台差异:Windows 全程不
chmod(那里chmod只切换只读位,动它反而是回归)。回滚 / 降级方式:单 commit,
git revert即可完全回到原行为,无数据迁移、无持久化状态变更。回滚后新装插件恢复为 0644(即回到当前的 bug 行为),不会留下任何不一致状态。提交前检查
git commit -s,见 DCO)bin/让宿主猜测)Review 反馈处理
两个自动 reviewer 各报了一个 P1,均已处理(
2e9f3649)。1. 已签名导出路径的 mode / 内容快照错位(Greptile) — 成立,已修。
readSignedDoc()与readSignedEntries()原先stat与readFile是两次独立调用,并发chmod落在中间会把改动前的 mode 配到改动后的内容上(内容与 SHA-256 自洽,校验发现不了,导出包执行位却是旧值)。现改为从同一个FileHandle取 stat 与内容。「先对尺寸再读内容」的顺序、两道MAX_SIGNATURE_FILE_BYTES检查、ENOENT → null与失败收敛为null的语义均保留,早退路径经finally关闭句柄。2. mode 未被签名认证(Codex) — 前提属实,已核实:
buildStatement()枚举全部条目并对(path, sha256, bytes)做 canonical 全等比对,内容与文件清单都被认证,但 mode 不在覆盖范围内。这个缺口是本 PR 引入的(改之前 mode 被完全忽略)。处理方式是「修掉可修的那半,另一半显式申明」:
& ~0o022)。原实现会原样采纳0o777/0o666,于是被篡改的包、甚至源文件权限没设好的正常包,都能把 group/world 可写文件装进插件内容目录,同机另一账号即可改写受害用户随后执行的插件代码 —— 这是比「翻转 +x」更实际的本机提权路径。改前此处恒为 0644,不存在该面。0755/0644/0700不变,0777 → 0755,0666 → 0644。只钳装入侧(安全边界),打包侧未动。schemaVersion: 2,验签必须同时继续接受存量 v1 签名包(存量插件兼容红线,不允许要求重装或重新确认),发布与审核流水线还要重签。已与需求方确认拆为独立 issue,不阻塞本次 bug 修复。维护者确认门后的第三轮修复(
09ac51a8)维护者确认门(#2956)的分析基于
origin/main快照而非 PR 工作树,其中两条已由 PR 内的提交处理或已过时;三条要求现已全部落地:mode 必须进入与内容同等的完整性边界 —— 给出依据后维持采纳,已撤回中途那版「签名包不采纳 mode」的分支(
b29c750b)。中途那版形式上满足要求,但代价是正式发布的插件在 macOS / Linux 上依旧启动不了随包程序、只有未签名包受益 —— 对主分发渠道等于没修(Codex 复审也独立指出了这点)。
维护者的原话是「现有证据不足以默认做出这一产品/安全决定」,所以补上了实算:钳位之后攻击者改不了文件内容、增删不了文件(
buildStatement()全量白名单 + 逐文件 sha256 + canonical 全等)、设不了 setuid/setgid/sticky、装不出 group/world 可写文件、剥不掉 owner 读写;剩下只有在内容已被哈希钉死的文件上翻转 r / x 位。去掉+x只是他本来就能造成的可用性破坏(改坏一字节即验签失败);加上+x作用在他无法选择内容的文件上,而执行流指向哪个文件由签名覆盖的 manifest 与插件自身代码决定 —— 拿不到代码执行。逐条分析见 维护者确认 · #2948 fix(desktop): 安装与打包 .cindy 时保留 Unix 执行位 #2956。失效条件写进代码(
extractToStaging()注释):若日后有任何逻辑开始依赖 mode 做安全判断,该前提即失效,届时必须把归一化 mode 纳入认证。并由用例钉住 —— 改成「签名包不采纳 mode」会让它红。更省的备选(若放行人坚持要认证 mode):不必改签名格式 ——
ghost.json本来就在签名覆盖范围内,在 manifest 里声明「哪些文件需要执行位」那份声明天然被签名认证,不需要 v2 的双轨验签与流水线重签。要这条说一声我改。把归一化后的 Unix mode 签进 .cindy 签名 statement(schemaVersion 2) #2966(v2 跟进)已按此决定关闭。导出快照竞态 —— 已修。同句柄只挡住 open 与 stat 之间的窗口,
chmod落在读取过程中仍能造出「读前的 mode + 读后的字节」。现已在读取后复验文件稳定态,复用ghostContentTree既有的sameStableFileState()(含ctimeNs,故覆盖chmod),不另造判据:签名文件不一致即抛错,statement 条目不一致返回null交给既有的整体重试。Windows 门禁要有真断言 —— 已修。原先只是在
win32跳过 mode 断言。现按平台分叉正面断言:win32断言chmod从未被调用,非win32断言确实调用过,避免这条路径以后静默退化成「什么都没做」也能绿。新增用例:已签名包(真实经
signGhostPackage用生成的 ed25519 key 签名)安装后,0755/0644与特殊位剥除的断言与未签名包完全一致;用例先自检包内确实带0755,保证它不是空转。一处需要单独点明的行为变化(超出「恢复执行位」本身)
既然 mode 现在是包内容的一部分,导出侧的一致性判据也随之收紧,请 review 时留意:
(rel, sha256)扩成(rel, sha256, unixPermissions)。因此两遍之间发生一次纯chmod(内容完全没变)现在会判定快照不一致并触发整体重读,而改动前会被忽略。ctimeNs,读取期间的chmod会让该条目返回null,交给既有的整体重试。这是必要的收紧 —— 否则会打出「一半旧 mode、一半新 mode」的包,内容与 SHA-256 却都自洽、校验发现不了。代价是导出在「导出期间有人
chmod插件目录」这种并发下会多重试一轮(重试仍失败才如实报错),属于罕见且正确的失败路径。残留风险(请放行人重点判断这一条)
签名包与未签名包统一恢复 mode,因此残留面是:能篡改包字节的攻击者可以在验签仍通过的前提下翻转 r / x 位。上面第 1 条已逐条算过后果 —— 只剩一个他原本就具备的 DoS,拿不到代码执行。这是有意接受的取舍,依据与失效条件都已落到代码注释与用例里。
+x使插件启动不了),以及把发布者自己发布、哈希已认证的字节变成可直接spawn。4755 → 755)。请放行人在三者中裁定:接受本 PR 现状(依据如上)、要求改成 manifest 声明式(更省,仍是认证过的)、或要求做 statement v2。我不自行推进。