fix(desktop): 插件运行授权改由 Host 批准 receipt 持有,与可变安装目录解耦 - #1080
Conversation
此前插件「用户批准过什么」的唯一事实来源是安装目录里的 ghost.json / .cindy-trust.json / .disabled,而这个目录是可变的,产品里还有多条路径把用户 与 Agent 引到那里去改(详情页可显示/复制/打开安装目录,Forge 不禁止拿已安装 目录当源码目录)。就地改写清单能让更新确认框的「旧版本」与权限 diff 以被改过 的现场为基线,未经确认的能力声明因此拿到运行授权。 把授权事实搬到安装目录之外: - 新增 ghostInstallReceipt.ts:一次明确的安装/更新确认写出 Host 持有的 receipt(批准 manifest + trust + 启停态 + 随机 revision),落在 owner-scoped 的 ghost-install-state 状态根,构造期断言内容根与状态根互不包含; GhostManager.list() 的 manifest / trust / enabled 一律只从 receipt 取, 安装目录里那三样退化为旧版本兼容镜像; - 无 receipt(旧安装)或 receipt 损坏一律 fail closed:列为停用、不许启用、 不参与技能落链,只能走一次完整重新确认恢复;插件页与详情页如实说明现状并 给出「重新确认权限」入口,不让它看起来只是被用户关掉了; - 版本与权限 diff 改吃 diffInstalledGhostPermissionItems,无批准基线时把候选 包的全部权限当新增项逐条列出; - 更新事务把批准态投影成 token 由 Renderer 回传、Main 现读比对,不一致即拒 (state-changed / PRECONDITION_FAILED),与既有 expectedPackageSha256 形成 对称; - Forge 的 scaffold / pack 按 realpath 拒绝 Host 受管根及其后代,覆盖大小写 折叠与软链 / junction 别名,pack 返回 SOURCE_IS_INSTALLED_PLUGIN,并同步 内嵌编写手册与工具契约错误码; - 越出沙箱的 skill 槽在确认时逐字节拷成带 revision 的批准快照,共享技能根的 链接指快照而不是可被改写的安装目录,旧 revision 在 receipt 提交后回收; - 内容目录与 receipt 的改写串成一条事务车道;启用需有效批准状态、停用无论 环境如何都必须成功;随包内置插件由 provisioning 逐字节对账后自动补批准, 用户无感。 安装内容的持续完整性校验仍未做:packageSha256 只是批准时点的来源指纹,运行期 没有任何地方校验它,逻辑页代码仍从可变安装目录现读。该残留缺口已写进 docs/dev-rules/plugin-security-and-authoring.md 第 6 节,避免后人误以为已有 内容完整性保证。 存量安装分三档影响,以及「市场 release 哈希是否应自动补批准」这一取舍,详见 PR 描述的「用户可见变化」——本次刻意不做自动补批准,留给维护者单独拍板。 关联 Issue: makecindy#636 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| apps/desktop/src/main/cindy-brain/GhostManager.ts | 将插件授权事实迁移到 Host receipt,串行化生命周期写操作,并在启用、更新和技能对账路径上执行 fail-closed 校验。 |
| apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts | 实现 schema v2 receipt、无歧义内容摘要、revision 技能快照及复制前后的一致性复核。 |
| apps/desktop/src/main/cindy-brain/ghostContentTree.ts | 统一插件内容遍历、路径段链接拒绝、普通文件判定和摘要编码策略。 |
| apps/desktop/src/main/cindy-brain/skillSlot.ts | 在建立或保留共享技能链接前强制验证批准快照,失败或异常时撤销链接。 |
| apps/desktop/src/main/cindy-brain/forge.ts | 阻止 scaffold 和 pack 使用受管根、其后代、其祖先或链接别名作为插件源码。 |
| apps/desktop/src/shared/ghost.ts | 扩展安装批准状态、token、权限差异及 InstalledGhost 的共享契约。 |
| apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx | 为需要重新确认的插件显示状态徽标、禁用开关并提供恢复入口。 |
| apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx | 在插件详情页展示重新确认说明和操作入口。 |
Sequence Diagram
sequenceDiagram
participant User as 用户
participant Renderer
participant Main as GhostManager
participant Receipt as Host receipt
participant Snapshot as 技能快照
participant Reconciler as 技能链接对账
participant Agent
User->>Renderer: 确认安装或更新
Renderer->>Main: 包 + expectedInstalledApproval
Main->>Receipt: 比对当前批准 revision
Main->>Main: 校验并切换插件内容
Main->>Snapshot: 复制、摘要校验、发布后复核
Main->>Receipt: 原子写入新批准事实
Main->>Reconciler: 广播状态变化并调度对账
Reconciler->>Receipt: 复核当前 revision
Reconciler->>Snapshot: 重算完整内容摘要
alt 快照有效
Reconciler->>Agent: 建立或保留共享技能链接
else 快照无效
Reconciler->>Agent: 撤销已有链接
end
Reviews (8): Last reviewed commit: "fix(desktop): 停用镜像丢失不再翻转随包批准的启停态,随包批准门加运..." | Re-trigger Greptile
There was a problem hiding this comment.
Pull request overview
This PR hardens Desktop plugin authorization by moving the “what the user approved” source of truth out of the mutable install directory into a Host-owned install receipt (with a random approval revision), and then wiring update/install flows, runtime gating, Forge authoring constraints, and UI affordances around that receipt-backed approval state.
Changes:
- Introduces Host-owned install receipts (manifest/trust/enabled/revision + locale/icon snapshots) and makes
GhostManager.list()derive runtime authorization solely from receipts (fail-closed when missing/corrupt). - Adds cross-process “approval token” binding to updates/market installs to detect state changes after permission review and reject with
PRECONDITION_FAILED. - Tightens Forge
pack/scaffoldto reject Host-managed roots (install root + approval-state root), and redirects skill-slot linking to approval-revision snapshots instead of the mutable install directory.
Reviewed changes
Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/cindy-tools/src/types.ts | Adds structured Forge refusal code for managed-root sources. |
| docs/dev-rules/plugin-security-and-authoring.md | Documents Host-owned approval receipts, fail-closed behavior, and Forge source isolation. |
| apps/desktop/src/shared/ghost.ts | Adds GhostInstallApproval, approval token helpers, and receipt-based permission diff helper. |
| apps/desktop/src/renderer/vite-env.d.ts | Updates renderer API typings for update/install approval token binding. |
| apps/desktop/src/renderer/lib/tests/ghostPanelBubbleState.test.ts | Updates test fixtures for required InstalledGhost.approval. |
| apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | Adds reapproval UI strings and approval-required error copy. |
| apps/desktop/src/renderer/i18n/locales/ko/common.json | Adds reapproval UI strings and approval-required error copy. |
| apps/desktop/src/renderer/i18n/locales/ja/common.json | Adds reapproval UI strings and approval-required error copy. |
| apps/desktop/src/renderer/i18n/locales/en/common.json | Adds reapproval UI strings and approval-required error copy. |
| apps/desktop/src/renderer/features/plugin/lib/pluginMarketPresentation.ts | Adds market reapproval routing + eligibility logic. |
| apps/desktop/src/renderer/features/plugin/lib/ghostPluginViewModel.ts | Exposes approval state in list/detail view models. |
| apps/desktop/src/renderer/features/plugin/lib/tests/pluginMarketPresentation.test.ts | Tests market reapproval routing/eligibility helpers. |
| apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx | Integrates receipt-based permission diff + reapproval action + token binding. |
| apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx | Adds reapproval CTA, disables toggles, and shows status notice when unapproved. |
| apps/desktop/src/renderer/features/plugin/tests/GhostPluginDetailSections.test.tsx | Tests detail UI behavior for legacy/invalid approval states. |
| apps/desktop/src/renderer/features/plugin/tests/GhostPluginCard.test.tsx | Tests card UI behavior for legacy/invalid approval states. |
| apps/desktop/src/renderer/cindy-brain/installFlow.tsx | Uses receipt-based permission diff + binds approval token on updates. |
| apps/desktop/src/renderer/cindy-brain/installErrorKey.ts | Maps approval-related PRECONDITION_FAILED to “reapproval required” UI error. |
| apps/desktop/src/renderer/cindy-brain/tests/useInstalledGhosts.test.tsx | Updates installed ghost fixtures to include approval. |
| apps/desktop/src/renderer/cindy-brain/tests/installFlow.test.tsx | Adds tests for passing approval token + “all permissions added” when unapproved. |
| apps/desktop/src/renderer/cindy-brain/tests/ghostTabPlugins.test.tsx | Updates fixtures to include approval. |
| apps/desktop/src/renderer/cindy-brain/tests/ghostPluginViewModel.test.ts | Tests carrying approval state through view-model mapping. |
| apps/desktop/src/renderer/cindy-brain/tests/ghostPanels.test.tsx | Updates fixtures to include approval. |
| apps/desktop/src/renderer/cindy-brain/tests/GhostPanelBubbleLayer.test.tsx | Updates fixtures to include approval. |
| apps/desktop/src/renderer/tests/planModeComposerEntry.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/renderer/tests/ghostComposerPlacement.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/renderer/tests/ghostCommandDecoration.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/preload/preload.ts | Updates exposed IPC signatures for approval token binding. |
| apps/desktop/src/main/plugin-market/service.ts | Enforces approval-token preconditions + receipt-based permission diff for market installs/updates. |
| apps/desktop/src/main/plugin-market/registerIpc.ts | Validates expectedInstalledApproval tokens for market IPC. |
| apps/desktop/src/main/plugin-market/tests/service.test.ts | Adds tests for approval-token races and precondition failures during download. |
| apps/desktop/src/main/mcp-integrations/ghost.ts | Passes managed-root forbiddance into Forge scaffold/pack MCP paths. |
| apps/desktop/src/main/ghost-panel-window/tests/controller.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/main/cindy-brain/skillSlot.ts | Repoints shared-skill links to approval-revision snapshots and manages two roots. |
| apps/desktop/src/main/cindy-brain/runtime/tests/GhostRuntime.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/main/cindy-brain/index.ts | Wires state root into GhostManager, maps new rejections to IPC errors, and binds market updates. |
| apps/desktop/src/main/cindy-brain/GhostManager.ts | Implements receipt-backed listing/enable/update/uninstall, mutation serialization, and built-in approvals. |
| apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts | New Host-owned receipt store with strict read/atomic write + skill snapshot management. |
| apps/desktop/src/main/cindy-brain/forge.ts | Adds managed-root forbiddance to scaffold/pack, and new structured error code. |
| apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts | Reports byte-reconciled bundled manifests for automatic receipt approval. |
| apps/desktop/src/main/cindy-brain/tests/skillSlot.test.ts | Updates tests for required approval-state root and snapshot link targeting. |
| apps/desktop/src/main/cindy-brain/tests/ghostSetupManifestTracker.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/main/cindy-brain/tests/GhostManager.test.ts | Adds extensive receipt/approval fail-closed, snapshot, and race-condition coverage. |
| apps/desktop/src/main/cindy-brain/tests/fsSlot.test.ts | Updates fixtures to include approval. |
| apps/desktop/src/main/cindy-brain/tests/forge.test.ts | Adds tests for managed-root pack/scaffold refusal and guide wording. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ec33f9aa2a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
处理 PR makecindy#1080 的两条 review 发现。 1) 随包对账写批准失败时 fail closed(index.ts) 走到 approveTrustedBundledInstall 的 catch 时,outcome.updated 那一支的内容目录 已被 swapInSeed 换成新种子字节,而旧 receipt 仍是授权事实。只 warn 后继续等于拿 旧批准跑新代码:新版删掉的 slot 仍被授予(fail-open)、版本与 trust 停在旧 revision、旧 revision 的技能快照还在生效。首装那一支本来就安全(没有旧 receipt → legacy-unapproved)。 改为撤掉这条陈旧批准,fail closed 到 legacy-unapproved;随包插件下一轮启动对账 会重新补批准,自愈,不需要用户介入。撤批准本身再失败时升级为 log.error,那才是 需要人介入的状态。 2) 快照重建复现装入侧的 SKILL.md 门槛(ghostInstallReceipt.ts) ensureSkillSnapshot 在快照缺失时从可变安装目录重建,此前直接 readFile,没有任何 stat / size 前置。后果不止把超大文件整份读进内存:把 frontmatter 写得与批准 manifest 一致、只让正文超限,setEnabled(true) 会返回 ok —— 启用这条路会批准一份 装入/更新永远不会接受的超大技能指令,两条路径的 size ceiling 不一致。 改为先 lstat 再读,用 GHOST_SKILL_MD_MAX_BYTES(与 GhostManager 装入校验同源,不 复制字面量)判定大小,并用 lstat 而非 stat 以拒绝软链与非普通文件,与本文件其余 位置一致。isFile() 那一半属于 defense-in-depth:软链方向在此之前已被 copyRegularDirectory 的 readdir 判定拦下,真正改变行为的是 size 判定。 回归覆盖(GhostManager.test.ts): - holds the install-time SKILL.md size ceiling when rebuilding from mutable install bytes —— 去掉守卫必红(setEnabled 返回 ok); - revoking approval fails the install closed, and a later bundled approval heals it —— 锁住第 1 条修复依赖的契约链:撤批准 → legacy-unapproved + enabled:false → setEnabled(true) 被 approval-required 拒 → 再次 approveTrustedBundledInstall 恢复。 reconcileBuiltinGhosts 是 index.ts 的私有函数且该模块会拉起整张 main 单例图,故 未给该 catch 分支本身写单测,只覆盖它依赖的契约。 另一条 review 反馈(prune 到 relink 之间的技能链接瞬时断链)核后未采纳:启动即 对账(index.ts registerGhostIpc)与 update 结尾的 onChanged 广播都会驱动对账自愈, 「悬空状态持续到下次启动」不成立;瞬时窗口是 pruneStaleSkillSnapshots 注释里写明 的有意取舍——让旧批准 revision 的技能继续对主 Agent 生效是更糟的方向。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts:132
GhostInstallReceiptStore.write()may leave behind an orphanedskill-snapshots/<id>/<revision>directory ifensureSkillSnapshot()succeeds but writing/renaming the receipt file fails (e.g. disk full / permission error). Because the receipt is the only “approved” fact source, this orphan snapshot will never be referenced but can accumulate on repeated failures. Consider cleaning up the newly-created snapshot target when the receipt commit fails, while avoiding deleting pre-existing snapshots during enable/disable toggles.
const root = this.rootDir();
await fs.promises.mkdir(root, { recursive: true });
try {
await this.ensureSkillSnapshot(receipt, options.skillSourceDir);
} catch (error) {
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 40fe328ae0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
本轮增量 commit 只动了三个文件(+78 / -1):index.ts 的 fail-closed 收敛、 ghostInstallReceipt.ts 的 SKILL.md size/stat 守卫、GhostManager.test.ts 的两个用例。 刻意追加 commit 而不是 amend,方便 reviewer 只看增量。 验证:pnpm --filter desktop typecheck PASS;插件相关全部目录定向跑 122 files / 1676 tests PASS;pnpm test:unit 结果与 PR 描述里那条一致(本机 cindy-media 的 8 个 suite 因 better-sqlite3 原生模块在 Windows 下 collect 失败, 已在未改动的 origin/main 上复现,非本改动引入)。 |
处理 PR makecindy#1080 对 40fe328 的二次 review 发现(P1「Fail closed when a skill snapshot is missing」)。 快照缺失时 setEnabled(id, true) 会从**可变安装目录**重建批准快照,而重建时唯一 的把关是 checkSkillMdConsistency —— 它只校验 frontmatter 的 name / description (skillSlot.ts:61-81),随后 copyRegularDirectory 把整个技能目录原样拷成新的批准 快照。于是保持 frontmatter 不变、改写 SKILL.md 正文或往技能目录塞辅助文件,就能在 一次「启用」里把一份没人确认过的技能指令固化成已批准快照并全局挂链,交给主 Agent 以用户全部权限执行,全程没有新的权限确认。 这直接推翻了本仓规则里那条不变量:确认框看到的 SKILL.md 必须就是 Agent 之后读到 的那份。严格说不是本 PR 引入的回归(改之前技能链接直接指向可变安装目录,正文漂移 立刻生效,更糟),但是本 PR 声称堵上、实际没堵严的洞。 修法: - receipt 新增必填字段 skillContentSha256(item.dir → sha256),由 hashApprovedSkillContent 从**这次批准的内容目录**现算:逐 item 目录取排序后的 相对路径 + 字节做规范化哈希,不跳过点文件(技能指令可引用目录内任意文件), 非普通条目一律拒(判据与 copyRegularDirectory 同源); - ensureSkillSnapshot 在从安装目录重建前重算并逐项比对,对不上即抛错 —— 启用被拒、 插件保持停用,只能走完整重新确认,不允许就地「自愈」成新批准。内容未被改动的 合法自愈场景不受影响; - validateReceipt 把该字段做成必填且键集须与清单声明严格一致:留「字段缺失就跳过 校验」的可选口子等于给漂移开一条绕过路径。receipt 格式尚未随任何版本发布,因此 不需要兼容旧 receipt,schemaVersion 保持 1; - install / update 的 receipt 创建从「解压前」移到「内容已落到 finalDir 之后」, 指纹必须从真实内容目录现算而非凭空构造;approveTrustedBundledInstall 也把该字段 纳入免写快路径的等值比较。 回归覆盖(GhostManager.test.ts): - refuses to rebuild a snapshot when only the SKILL.md body drifted; - refuses to rebuild a snapshot when a helper file was added to the skill directory; 以上两条去掉指纹校验必红。 - still heals a deleted snapshot when the installed skill bytes are untouched —— 确认没把合法自愈一起堵死; - invalidates a receipt whose skill content digests no longer match the manifest —— 必填字段缺失判 invalid、fail closed。 同步修正 docs/dev-rules/plugin-security-and-authoring.md:原文把快照的保证写得比 实现强,现改为写明 checkSkillMdConsistency 只管 frontmatter、重建必须逐字节对上 指纹、skillContentSha256 是运行期判据且必填;review 清单第 3 条增加对「任何从安装 目录取字节的路径」的检查项。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
|
⏸️ 本 PR 修改规则文档 plugin-security-and-authoring.md,触发 rules 签字门(审查判据变更),已开讨论 issue 等待 roster 确认。如需放行:在 PR 评论或讨论 issue 里回复同意,或摘掉 讨论 issue:#1091 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3387f85a7b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
处理 PR makecindy#1080 的一条 review 反馈(copyRegularDirectory 依赖 Dirent.isDirectory()/isFile(),若平台把软链/junction 报成 directory 就会跟进去, 把技能目录之外的字节拷进 Host 批准快照)。 先说实测结论:**当前平台上该利用不成立**。Node v22.14.0 / Windows 10 实测, readdir(withFileTypes) 对 directory junction 与目录符号链接都报 isSymbolicLink=true、isDirectory=false(当前 libuv 先判 FILE_ATTRIBUTE_REPARSE_POINT 再判 FILE_ATTRIBUTE_DIRECTORY),因此两者本来就落进 else 分支被 "skill snapshot rejects non-regular entry" 拒掉。CI 用的同样是 Node 22。 但反馈指出的**脆弱性是真的**:这条安全判据建立在 libuv 的 dirent 类型位行为上, 而 Node 公开 API 并没有保证 reparse point 一定报成 link。判据不该依赖未文档化的 实现细节,尤其 Windows junction 创建不需要管理员权限、是本平台成本最低的一条把 外部字节拉进批准快照的路子。 因此本提交不修"现存漏洞",而是移除对 Dirent 类型位的依赖: - copyRegularDirectory 与 hashApprovedSkillContent 两侧改为逐条 lstat,显式拒绝 isSymbolicLink(),再用 lstat 结果判定 directory / file; - 两侧判据保持同形 —— 否则指纹算的字节集与快照拷的字节集可能不是同一组。 hashApprovedDirectory(随包内容哈希)沿用同一 else-throw 结构、同样拒绝非普通 条目,且只作审计不参与运行授权判定,故未改动。 测试(GhostManager.test.ts)新增 refuses to follow a link planted inside the skill directory when rebuilding: 在技能目录里种一个 junction(Windows 无需管理员权限)指向外部目录,断言重建被拒、 快照不落地,且状态根内不出现任何来自技能目录之外的字节。 如实说明该用例的性质:它是**行为钉住**用例,不是回归用例 —— 已实测在把判据还原成 Dirent 类型位后它依然通过,因为当前 libuv 就把 junction 报成 link。它的价值在于 一旦将来 Node/libuv 改变 dirent 语义,这条会立刻变红。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@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: 0d627c0cb6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@Yuewei-LL 👋 这个 PR 还有 8 条 review conversation 没 resolve(apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts / apps/desktop/src/main/cindy-brain/index.ts / apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts / apps/desktop/src/main/cindy-brain/forge.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
处理 PR makecindy#1080 第 3 轮的两条 P1,以及随后两轮 review 的收尾项。 1) 快照重建的 check-then-use 窗口(greptile P1 SECURITY) ensureSkillSnapshot 原来先用 hashApprovedSkillContent 读一遍源目录做校验,再用 copyRegularDirectory 又读一遍源目录做复制。两次读之间同权限进程可以换字节,复制 出来的快照因此可能不是被校验过的那一份,rename 之后它就成了「已批准」,主 Agent 经共享技能链接以用户全部权限执行。这个窗口在实现 3387f85 时就存在,是漏报。 改为先把字节复制进状态根的临时目录,再对 temp 里那份即将成为快照的字节做全部权威 校验(尺寸上限 → 指纹逐字节比对 → frontmatter 一致性),通过才 rename 就位。源目录 只剩「复制来源」一个角色,不再参与任何判定 —— 消除的是这一类,不是缩小窗口。复制前 仍留一次源目录尺寸预检,但注释写明它不是安全边界、只为早失败。 残留窗口如实记录:rename 之前 temp 位于状态根内、同权限进程仍可改写,本地校验能保证 「被校验的就是被复制的」,保证不到「rename 的就是被校验的」。 2) 撤销陈旧批准失败时的残留 fail-open(Codex P1) 40fe328 只在撤销成功时 fail closed,撤销失败走 log.error 后继续 —— 而撤销失败的 成因(状态根不可写)与最初写批准失败的成因是同一个,两个失败强相关。 把 fail-closed 契约收进 removeInstallApproval 本身:删得掉就删 receipt 与技能快照, 删不掉就转进程内隔离(untrustedApprovals)。此刻状态根已不可写,再指望往里写点什么 表达「已失效」并不可靠,内存标记是唯一还能用的机制。隔离由读批准状态的唯一入口 readApproval() 统一投影成 invalid,下一轮启动对账成功即自愈。 顺带把批准状态读取从四处(list / setEnabled / update 的 token 比对 / 随包免写快路径) 收敛成唯一入口,uninstall 也改走同一个撤销入口 —— 「同一判定散落多处」正是本 PR 前 几轮补一处漏一处的机制成因。撤销后补上 runtime / node broker / agent slot 三连熄灯: 「撤销后不再被授权运行」对已经跑起来的实例并不自动成立。 3) review 收尾项 - 技能内容指纹的尺寸上限校验移到算指纹之前:上限要在权威路径上真正生效,且不为一份 注定被拒的字节先付整趟读取成本; - hashApprovedSkillContent 与 hashApprovedDirectory 改流式喂 hash(摘要值不变,实测 四类输入一致),技能目录里除 SKILL.md 之外的文件没有尺寸上限,整份读会被塞进来的 超大辅助文件撑爆; - hashApprovedDirectory 的条目判据对齐姊妹函数(逐条 lstat、显式拒 isSymbolicLink), 它只是漂移检测器不作授权判据,对齐是为消除判据分叉; - temp 侧 lstat 包领域错误,不再抛裸 ENOENT; - logger 的 error 是可选方法,隔离信号退化到 warn,不因注入实现缺 error 就静默丢弃。 回归覆盖(GhostManager.test.ts,均逐一还原成修复前行为验证过精确变红): - rejects bytes swapped after the hash check but before the snapshot copy finishes - keeps an install unusable when a stale approval cannot be revoked - applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot 文档(plugin-security-and-authoring.md):§3 把「先复制进 temp、再校验 temp、通过才 rename」的顺序写成安全性质并禁止改回,补上撤销失败→进程内隔离这条通往 invalid 的 路径;§6 新增一条与「内容根字节可变」并列的独立缺口「批准状态根自身没有写保护」, 登记 temp 窗口与 receipt 无签名可伪造两个后果。 两处不在本 PR 修的毛边已写进 PR 描述交维护者拍板:随包插件被隔离时 UI 恢复提示是 死路(正确文案应是重启应用)、被熄灯的常驻随包插件同会话内不自动重启。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
MagicLizi
left a comment
There was a problem hiding this comment.
Review: 1 P1 (阻塞合并)
P1: index.ts:794 — reconcileBuiltinGhostsLocked 每次 tick 从可变安装目录重新推导 receipt.enabled,静默覆盖用户的显式禁用决策
reconcileBuiltinGhostsLocked()(L789–795)在每次 reconcile tick(启动、登录、locale 变更)时从 .disabled 标记文件重新计算 enabled 参数传给 approveTrustedBundledInstall()。在已批准且内容匹配的稳态分支里,代码执行 if (current.receipt.enabled !== enabled) { write({...current.receipt, enabled}) } —— 只因标记文件与 receipt 不一致就覆写 receipt。
后果:用户通过 setEnabled('x', false) 禁用内置插件(正确写入 receipt.enabled=false 并创建 .disabled)→ 外部因素移除 .disabled 文件(AV 隔离/恢复、同步冲突解析器、手动清理)→ 下次 reconcile 静默翻转 receipt 回 enabled=true → 插件恢复完整能力集(tool 暴露、message hook、卡片 slot、外部链接等),无弹窗、无重新授权、无审计记录。
这恰好重新引入了本 PR 声明要消除的模式(PR body: "trust / enabled 一律只从 receipt 取,安装目录里那三样退化为旧版本兼容镜像")。reconcileBuiltinGhostsLocked 在仓库中零测试覆盖。
建议修复:在 current.state === 'approved' && content-matches 分支中,保持 current.receipt.enabled 不变,不接受 marker-derived override;仅在引导全新 receipt 时(else 分支,尚无其他 truth source)才参考 .disabled 标记。
P2(不阻塞)
forge.ts:600: Host-managed-root 守卫用fs.promises.realpath而非.native()变体;Windows junction 正确性依赖一次性手动验证而非 CI(已在 PR body 如实披露)。forge.ts:447:scaffoldGhostDir的手动 ancestor-walk 与新抽取的resolveThroughExistingAncestor()逻辑重复。GhostManager.ts:1055:approveTrustedBundledInstall无运行时断言强制其 builtin-only 使用边界(当前靠单一调用者纪律保证安全)。
对应第 6 轮 review(1 P1 + 3 P2)。
## P1:`.disabled` 镜像丢失会静默重新启用用户显式停用的插件
reviewer 描述的因果链逐环成立:setEnabled(false) 双写 receipt.enabled=false 与
`.disabled` 镜像 → 外部因素(AV 隔离恢复/同步冲突解析/手动清理)移除镜像文件 →
下一轮随包对账把 `!existsSync(.disabled)` 喂给 approveTrustedBundledInstall,稳态
分支只因"镜像读数 ≠ receipt"就覆写 receipt 回 enabled=true → 插件恢复完整能力集,
无确认、无审计。这正是本 PR 声称消除的"可变安装目录反向覆盖授权事实"。比 review
指出的更重一层:带 skill 槽的随包插件会随之重新挂进全局技能链,以用户全部权限执行。
**没有原样采用建议修法**("稳态分支保持 receipt.enabled 不变,只在引导全新 receipt
时参考镜像"),它有两个缺口:
- 它把"镜像说停用、receipt 说启用"也一起忽略了。那是旧客户端唯一能表达停用的方式
(老版本只写镜像不写 receipt),PR 承诺过回滚兼容;仓库规则也写明收敛方向不对称:
停用必须永远能成功。
- 它只堵稳态分支。随包更新走的"建全新 receipt"分支用同一个镜像推导 enabled,镜像在
更新 tick 之前丢失,新 receipt 照样 enabled=true —— 同一个洞换条路。
实际修法:合并规则收进 approveTrustedBundledInstall 一处,对两个分支同时生效 ——
镜像读数**只往停用方向合并,不往启用方向翻**:
enabled = prior === undefined ? marker : (marker && prior)
- 镜像说停用 → 一律照办(旧客户端停用生效,安全方向);
- 镜像说启用、receipt 说停用 → 保持停用(修掉 P1),并把 `.disabled` 补写回去,
守住"回滚到旧客户端按镜像判启停"的降级承诺(写不进只 warn,receipt 仍是权威);
- 无 receipt → 按镜像(引导/改名停用态补挂不受影响);
- 重新启用只有用户显式 setEnabled(true) 一条路。
prior 直接读盘上 receipt 而非 readApproval 投影:隔离态 receipt 不作授权事实,但
"曾经停用"只用于往下拉,是 fail closed 方向 —— 否则"隔离 + 镜像同时丢失"的组合
仍会让自愈把插件带回启用。
行为代价如实说明:用户在旧客户端里启用(删镜像)后升回新客户端,插件保持停用、
需再点一次启用。方向安全、路径罕见,判为可接受。
## P2.3:approveTrustedBundledInstall 加 builtin-only 运行期断言
该入口不经用户确认就铸出批准,此前边界只靠"唯一调用者是随包对账"的纪律。新增注入
式 isTrustedBundledId(生产接线 = id 在随包种子清单里,listBuiltinSeedIds),不在
清单一律抛错。做成注入而非硬编码前缀:企业种子 id 不带 cindy- 前缀,清单才是事实。
## P2.2:scaffoldGhostDir 的手写祖先 walk 去重
与 resolveThroughExistingAncestor 是同一段逻辑的两份实现(差异只有错误处理形态),
改为共用 helper、调用点包 INTERNAL。同一判定散落多处正是本 PR 反复出问题的形态。
## P2.1:forge 的 realpath 统一 .native 变体
fs.promises.realpath 没有 native 变体,promisify(fs.realpath.native) 一次,workdir
钳制/受管根解析/打包源目录三处统一。此前判为"一致性问题而非洞"(最终比较侧
isPathInsideDir 已做 win32 大小写折叠),但解析器不该是全仓唯一的例外,统一后也
消掉了"两种实现是否在多层 junction 链上等价"这个未实测的疑问。
## 回归覆盖(前两条已实测:还原成旧合并规则后精确变红)
- keeps a receipt-pinned disable when the .disabled mirror was lost, and rewrites
the mirror —— P1 主用例,兼断言镜像回写
- a bundled update keeps the receipt-pinned disable even when the marker was lost
—— 钉住"更新分支共用同一条合并规则",只堵稳态分支时此用例红
- an old-client style .disabled marker still turns a bundled receipt off ——
钉住非对称的另一半(停用方向必须照办),防止把修复做过头
- refuses to mint a bundled approval for an id outside the seed roster
reconcileBuiltinGhosts 本身仍无直测(私有函数 + 拉起整张 main 单例图,与
r3682452719 里说明的一致);上述用例直测它依赖的公开入口契约。
## 验证
pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 77 files /
1116 tests PASS;pnpm test:unit 16964 passed / 断言层 0 失败(8 个 collect 失败
仍全为本机 cindy-media / better-sqlite3 既有环境问题,上一轮的 review-plugin
flake 本轮未复现)。
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
|
@MagicLizi 已修(commit ac6a812),P1 与三条 P2 全部处理。逐条说明,其中 P1 没有原样 P1:确认成立,已修——但修法与建议不同因果链我逐环核过,全部成立: 建议修法("稳态分支保持 receipt.enabled 不变,只在引导全新 receipt 时参考镜像")有
实际修法:合并规则收进
prior 直接读盘上 receipt 而非 readApproval 投影:隔离态 receipt 不作授权事实,但 行为代价如实说明:用户在旧客户端里启用(删镜像)后升回新客户端,插件保持停用、需再点 关于「reconcileBuiltinGhosts 零测试覆盖」:属实,此前在 r3682452719 也说明过成因
一处小勘误:触发点是 startup / auth-change / legacy-recovery / restore 四个,不含 P2 三条都做了
验证pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 77 files / |
|
@Yuewei-LL 👋 这个 PR 目前与 请在本地 merge 最新的 |
MagicLizi
left a comment
There was a problem hiding this comment.
审查通过(heavy tier),零 P0/P1。安全扫描通过。
三项重点安全验证均独立确认:
- Receipt 伪造/重放:Renderer→Main 的确认 token 由 Main 从 live state 重新计算,不可重放;残留的 same-privilege 本地伪造已在 plugin-security-and-authoring.md 中显式声明为已知限制
- 路径穿越:所有 receipt/skill-snapshot 路径经
isValidGhostId/UUID 格式校验 +ghostContentTree.ts逐段 lstat 解析(拒绝 symlink/junction);测试覆盖了双向包含攻击 - 迁移安全:
list()按 content-directory 存在性枚举而非 receipt 存在性,孤立 receipt 不能复活已删插件;uninstall()先删内容再 best-effort 清 receipt
额外确认:内容哈希长度前缀防碰撞、skill-snapshot digest 不匹配时自动断链、i18n 四语言键一致。
|
主干代合并:冲突文件 这个 receipt 把安装目录从授权事实里摘干净了——以前「就地改 ghost.json 绕权限 diff」那个洞真打实了,而且文档诚实登记了剩余两个 gap,下次改就不用重新考古。 |
#1080 的批准 receipt 改造把「无 receipt = 不构成运行授权」一次性作用到全部存量 安装,只给随包内置插件留了自动补批准的路,市场与本地安装没有 backfill 路径。 结果升级后所有非随包插件同时变成停用、必须逐个重新确认,本地包还要求重新提供 原始 .cindy 文件——包已丢失就无从恢复。安全方向是对的,落地方式把一次内部机制 升级变成了全量用户故障。 新增 plugin-security-and-authoring.md 第 5 节「存量插件兼容:升级必须无感」: - 插件系统改动(批准状态 schema/指纹编码/manifest 校验/slot 形态/快照布局/ 安装根与状态根/.cindy 包格式/管子协议/内置 id)必须向下兼容;判据是用户 升级后什么都不做时的可用性,不是代码路径没报错。 - 新增校验或必填字段默认自带迁移(backfill),不是自带拒绝——「老数据缺新字段」 是升级前的正常历史状态,不是攻击证据。 - 迁移不得成为扩权或降级通道:只在从未有过 receipt/已判损坏时发生,权限集原样 不扩权,来源记日志;迁移出的授权只等价于旧版本已给出的授权,不等价于一次新的 用户确认。 - 自动做不到时四条兜底义务:能自动就别打扰用户、明确提示 + 一次性批量恢复入口、 不丢凭证与偏好、留回滚余地(新状态被旧版本读到不判损坏)。 - 测试门槛:必须有基于旧布局 fixture 的升级用例,只测全新安装不算覆盖。 - 漏迁移 = P0,与 mobile 冷更同级,需把关人对该影响明确确认。 配套接线:AGENTS.md 触发条件、REVIEW.md 审阅口径、PR 模板风险勾选项与说明、 Review 清单 6.6、docs/README.md 与 docs/dev-rules/README.md 索引。原第 5–7 节 顺延为 6–8 节,交叉引用同步更新。 一并订正两处影响面表述:随包内置插件由 provisioning 逐字节对账后走 approveTrustedBundledInstall 自动补批准、不受影响,因此是「全部非随包插件」失效 而非「全部插件」;并补上本地包在原始 .cindy 丢失时不可恢复这一更重的后果。 测试:pnpm test:unit 全绿(exit 0;apps/desktop unit 另行单独复核 PASS 121.6s)。 本次改动只含 .md,不涉及任何 package 的 typecheck 面。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lizi <jiali@magiclizi.com>
…rity" This reverts commit 890d4b3, reversing changes made to b7c098253ae0f1a03f43b1a0e6b53ad1e35f5d0e. 回滚原因:违反 docs/dev-rules/plugin-security-and-authoring.md 第 5 节「存量插件 兼容:升级必须无感」红线(该节与本次回滚同批立规,见 b7ebb8f)。 #1080 把「无批准 receipt = 不构成运行授权」一次性作用到全部存量安装,只给随包内置 插件留了自动补批准的路(provisioning 逐字节对账后走 approveTrustedBundledInstall), 市场与本地安装没有 backfill 路径。实机结果:升级后全部非随包插件同时变成停用、开关 置灰、逐个挂上「需要重新确认权限」;市场包需手动点一次重装确认,本地包要求重新提供 原始 .cindy 文件——包已丢失即无从恢复。这正是 #1080「手工验证」一节自列为待补的第 1 条,合并前未实机跑过。 安全方向是对的:#636 的漏洞(就地改写安装目录,让更新确认框的权限 diff 以被改过的 现场为基线显示「无新增」,未经确认的 slot 因此拿到运行授权)真实存在。回滚后该漏洞 重新敞开,已在第 7 节登记为【待重做】并写明重做时必须一并带上第 5 节要求的迁移; 详细验收标准另见本次一并开出的 issue。 冲突处理(docs/dev-rules/plugin-security-and-authoring.md,按「回滚实现、保留规则」解): - 第 3 节撤回 receipt / ghostContentTree / 强化版 skill 槽的描述,回到 #1080 之前 - 保留第 5 节存量插件兼容红线(b7ebb8f2 新立,正是用来约束重做) - Review 清单撤回 #1080 的第 6/6.5 条,保留存量兼容条目并重排为 5–8;措辞从 「receipt」泛化为「批准状态记录」,因为该机制当前已不存在 - 第 7 节原「未修复:存量安装没有 receipt backfill」改写为「待重做:授权事实与可变 安装目录解耦(原 #636)」,写明漏洞当前敞开、以及重做时的迁移与恢复路径要求 - 第 5 节历史教训补上「已于 2026-08-01 回滚」 工作区里 AGENTS.md 与 REVIEW.md 另有他人未提交的改动,本次未纳入。 验证:pnpm test:unit 全绿(exit 0;apps/desktop unit PASS 113.5s;0 FAIL); pnpm --filter desktop typecheck、pnpm --filter cindy-tools typecheck 均通过。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lizi <jiali@magiclizi.com>
|
这个 PR 已在 简述:把「无批准 receipt = 不构成运行授权」一次性作用到全部存量安装,只给随包内置插件留了自动补批准的路( 安全方向是对的,#636 的漏洞真实存在、现在随回滚重新敞开,已在 同批新立了第 5 节「存量插件兼容:升级必须无感」红线( 注:本想把 #1243 assign 给你,但 GitHub 拒绝(非本仓 collaborator),所以在这里 @ 一下 —— @Yuewei-LL |
…ycle-readiness main 在 eb6556d 整体回滚了 makecindy#1080(插件批准态完整性),同时以 b7ebb8f 立了 plugin-security-and-authoring.md 第 5 节「存量插件兼容 = 红线」。冲突就是上一轮 合并时手工并入的那些行现在要被撤回,按 revert commit 自己的口径解:回滚实现、 保留规则。 ## 冲突解决(3 处,全部撤回 makecindy#1080 引入的引用) - installErrorKey.ts: 撤回 PRECONDITION_FAILED case(对应 i18n key approvalRequired 已随 revert 删除),保留本分支的 SETUP_STATUS_UNAVAILABLE - GhostPluginDetailView.tsx: 开关撤回 needsReapproval 禁用条件,保留本分支的 Tip 包裹 + aria key;版本号后的就绪徽章恢复无条件渲染 - GhostPluginPage.tsx: 回到 diffGhostPermissionItems,撤回 expectedInstalledApproval;卡片撤回 needsReapproval,保留 useGhostReadiness 上一轮为了避免两个徽章并存而加的「未批准态不叠就绪徽章」也随之撤回—— needsReapproval 这个状态在 main 上已不存在。 ## 第 5 节红线自查:存量插件影响 = 无 本链路唯一新增的落盘物是 ghost-credential-rejections.json(owner 作用域 userData 下的独立文件): - 老版本 userData 里没有该文件 → ENOENT 按空账处理,已配置的存量插件判定 不变、照旧可用,不要求重装/重新确认/重填凭证 - 读取不创建文件,未知字段忽略而不判损坏(留回滚余地) - 生命周期投影全部是现有存储的现查读取(GhostManager 启用态、oauth 账号面、 连接清单、kv、manifest),没有新增 schema、没有新增必填字段,因此不存在 需要 backfill 的旧数据 - 未就绪插件是**降级暴露**(花名册保留、不派发工具 + 引导配置),不是停用: 开关照旧可点、凭证与偏好不动。此前已配置好的插件升级后仍判 ready 补 3 条「从旧状态升级」用例(ghostCredentialRejections.test.ts):台账文件 不存在 → 空账且不被创建;无台账时折算为恒等变换、ready 不会降 required; 含未知字段的台账仍可读。 验证:desktop typecheck 通过;定向单测 src/main/cindy-brain、 src/main/mcp-integrations、src/main/plugin-market、renderer 插件相关 135 files / 1841 tests 全绿(本次新增 3 条后为 1844)。全量单测交 CI。 Signed-off-by: xxxcc <xxxcc123@126.com>
plugin-security-and-authoring.md 第 5 节已有「插件基座改动一律走白名单确认门」的 正文(随 eb6556d 一并落地),但两个入口还没接上:AGENTS.md 的触发条件与 REVIEW.md 的审阅口径都不提这道门,Agent 与 review 因此不会被引导去读它。本次补齐: - AGENTS.md:在存量插件兼容红线后写明改到插件基座(运行时/沙箱、批准状态记录、 能力 slot、打包与内容判据、manifest 契约、装入与权限确认 UI、已装列表投影)的 PR 一律走确认门,需放行人明确 Approve,不看 diff 大小、不因「是 bugfix/纯技术改动」 豁免。 - REVIEW.md:存量插件兼容条目补上「同时要走白名单确认门、命中就在评论里点名放行人」。 一并修 eb6556d 回滚留下的两处不一致: - REVIEW.md 指的「Review 清单 6.6」已悬空——回滚撤回了 #1080 加的第 6/6.5 条, 存量兼容条目重排为第 5 条,引用同步改正。 - 两处「批准 receipt」指向已被回滚删除的机制,泛化为「批准状态记录(receipt 一类)」, 与 plugin-security-and-authoring.md 正文口径一致。全仓已复查无其它悬空引用。 验证:pnpm test:unit 全绿(exit 0;apps/desktop unit PASS 110.4s;0 FAIL)。本次改动 只含 .md,不涉及任何 package 的 typecheck 面。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Lizi <jiali@magiclizi.com>
本轮 4 条未 resolve review + 1 条折叠意见: 1. 租约跨实例失效(P1,Greptile + Codex)。`PluginMarketService` 每次操作都 `new MarketSourceManager(...)`,而引用计数是实例字段 —— 安装 manager 持有 的租约对刷新 manager 完全不可见,刷新照样删掉正在被打包读取的旧版本,上一轮 加的引用计数跨操作等于没做。注册表(versionRefs / pendingDeletes / activeIncoming)改为**模块级、按绝对路径为键**:受保护的对象本来就是文件系统 路径,按路径为键才是正确作用域;owner 隔离由 ownerScopedUserDataPath 编进 cloneRoot 的路径自带。租约测试改为读取与刷新走**两个不同的 manager 实例**, 与生产一致。 2. 激活后不得回收新版本(P2)。原 catch 覆盖了 activate + prune + store.update: 元数据写入失败(磁盘满/只读/文件锁)时指针已指向 newDir、旧版本已被清理, 却仍把 newDir 删掉 —— 留下指向不存在目录的指针,整个来源从列表消失。现在 可回滚的清理只覆盖"落位 + 激活"这一段,且删除前用指针再核对一次;激活之后 的失败只如实上报,绝不回收已生效的缓存。 3. 服务端安装入口同口径重算冲突(P2)。快照按"服务端目录 + 全部自定义来源"合并 计数标 conflict 并禁用,但 detail() 传空重复集合把它恢复成可安装,install() 也只统计服务端目录内部重名 —— 普通 UI 流程或直接 IPC 都能装上本该被冲突闸 拒绝的服务端项并抢占 ghostId。抽出 crossSourceDuplicateGhostIds / assertSourceOwnsGhostId,服务端与自定义两条安装路径、两个详情入口共用同一 口径,已拥有安装记录的来源保留所有权。 4. sparse 克隆不再留 detached HEAD(Copilot 折叠意见)。无显式 ref 时原先 `checkout HEAD`,导致刷新的 `git pull --ff-only` 稳定失败并回落整仓重克隆 —— sparse 源每次刷新都全量 clone,快进路径实际是死代码。改为解析 origin/HEAD 取默认分支并 checkout 该分支,保留跟踪关系;解析失败才退回 HEAD。 合并 upstream/main:upstream 已 revert makecindy#1080(批准 receipt 改造),因此 expectedInstalledApproval / ghostInstallApprovalToken / diffInstalledGhostPermissionItems / forbiddenRootDirs / resolveThroughExistingAncestor / onReapprove 全部不再存在,上一轮为并存而保留 的这些引用同步撤掉,自定义源安装的权限对比回到 diffGhostPermissionItems。 存量插件影响:无。改的是市场克隆缓存布局(versions/ + current 指针 + incoming/), 不触及已安装插件的包目录、批准状态与账本 schema;旧布局(槽目录直接是缓存)由 resolveCurrentVersionSync 自动迁移进版本目录,升级后用户无需任何操作,已装、 已批准、已启用的插件照旧可用。基于旧布局的升级用例见 sources-manager.test.ts 的 legacy 迁移用例。 验证:desktop main(plugin-market 149 全过)、renderer 6840 全过、typecheck、 check:i18n、check:i18n-glossary 通过。新增 5 个用例均验证过"去掉修复即失败"。 scriptRunnerPythonProtocol 与 makerSendToSessionOrdering 的 6 个失败在移除本次 改动后同样存在(来自合并进来的 upstream/main),与本 PR 无关。 Signed-off-by: xxxcc <xxxcc123@126.com>
* feat(desktop): 支持 Git/本地自定义插件市场源
- 新增 plugin-market/sources:来源解析(GitHub shorthand/Git URL/本地路径)、
Git 克隆(稀疏检出/staging 原子替换/GIT_TERMINAL_PROMPT=0/错误分类)、
Codex 兼容 marketplace.json 发现、sources.v1.json 持久化与 SourceManager
- 市场快照聚合自定义来源:合成 pluginId/releaseId 复用既有 detail/install/
uninstall 与 update-available 机制,ghostId 跨源冲突全局判定(先装先得),
服务端不可用时自定义市场仍可用
- 目录插件安装复用 forge 打包核心(新抽 packGhostDirToFile)与
installOrUpdateMarketGhostPackage;自定义源不享受官方前缀豁免,
安装前重读清单逐字比对防确认后篡改
- UI:筛选 tab 新增"自定义"(无源时隐藏,≥2 源分组小标题)、添加插件菜单
新增"添加插件市场…"对话框(添加表单 + 源列表刷新/移除 + Git 前置检测),
移除确认框层级提升至 z-10002 避免被双层 Dialog 遮挡
- 自定义来源的安全说明与安装确认文案如实区分(未经服务端 SHA-256 校验),
4 语言 i18n 落地
Signed-off-by: xxxcc <xxxcc123@126.com>
* docs: add custom marketplace UI screenshot
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 修复自定义市场源 review 安全与正确性问题
- parse: 拒绝内嵌凭证的 Git URL(https 用户/密码、ssh 密码位),
避免明文凭证写入 sources.v1.json 并回传 Renderer
- install: 自定义市场安装前比对用户审阅的完整 manifest(经 IPC
expectedManifest 传递),保持 id/version 不变但篡改权限声明的
ghost.json 将被 PRECONDITION_FAILED 拒绝
- discover: 用 realpath 校验插件目录真实路径仍在市场根目录内,
市场内 symlink 指向外部的条目跳过
- sources: Git 源刷新失败后的整目录重克隆先在 staging 完成完整
发现验证再替换旧缓存,远端损坏时保留上一次可用内容
- git: 普通 clone 后显式 checkout ref,--branch 无法检出裸 commit SHA
- forge: packGhostDirToFile 的最终写文件失败纳入 ForgePackResult
结构化错误,不再以异常逃逸契约
- Renderer: 添加市场对话框识别反斜杠开头的 Windows UNC/绝对路径;
移除市场失败改为与刷新一致的内联错误展示
- i18n: 新增 credentialsNotAllowed 错误文案(4 语言)
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 修复自定义市场源第二轮 review 正确性与并发问题
- forge: packGhostDir 的最终 writeFile 纳入 ForgePackResult 结构化
错误,与 packGhostDirToFile 对齐,源码目录只读/磁盘满时返回
{ ok: false, errorCode: 'INTERNAL' } 而非 reject
- service: 自定义插件 install/uninstall 互斥键统一为规范化
pluginId,消除两个窗口并发更新与卸载的竞态(误首装启用 /
账本与文件状态颠倒)
- service: customToItem 让已拥有安装记录的来源豁免 duplicate
conflict,重复 ghostId 只标未拥有安装的竞争来源,避免已安装
插件被降格、失去市场更新入口(先安装者优先)
- sources: Git 源刷新统一 staging 化——复制现有缓存到 staging、
在 staging 内快进并完整发现验证,成功后才原子替换旧缓存;
修复快进成功但新内容损坏时原地覆盖有效缓存的问题
- sources: fetchMarketplace 支持注入工作目录(cwd),供 staging
内快进使用;本地源刷新分支 lastSyncedAt 单次取值复用,避免
持久化值与 UI 展示毫秒级不一致
- i18n: credentialsNotAllowed 文案缩进对齐同对象其它 key
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 修复自定义市场源第三轮 review 安全与正确性问题
- service: snapshot 在发现自定义市场前先捕获 owner 并绑定其
作用域,跨 await 后用 generation 校验会话;账号切换期间不再
按调用时 owner 现查 store/目录,直接降级为空并标记
session-switching,避免账号 A 的插件数据泄漏给 B 的 Renderer
- sources: Git 源刷新的缓存交换改为可回滚——旧目录先原子改名
为备份、staging 落位成功后删备份;rename 失败(Windows 文件锁/
权限/瞬时 I/O)时从备份恢复 cloneDir,不再先删旧缓存再 rename
- parse: 稀疏路径拒绝以 - 开头的值(--stdin 会让 sparse-checkout
读 stdin 直至超时);git sparse-checkout set 参数加 -- 终止选项
解析作为双保险
- renderer: MarketplaceSourcesDialog / MarketplaceGuideDialog 改用
显式 type-only import(CSSProperties / ReactNode),不再依赖 React
UMD 全局命名空间
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 修复自定义市场源第四轮 review 账户绑定与回滚问题
- service: 自定义安装打包完成后、装出运行前经 beforeCommit 重新
校验最初捕获的 owner,避免打包期间账号切换把 A 审阅的插件装进
当前账户 B 的运行时(installCustomMarketPlugin 新增可选校验钩)
- service: listSources 返回前校验会话 generation,异步发现期间
账号切换时拒绝,不再把 A 的私有仓库 URL/本地路径发给 B 的 Renderer
- service: 服务端 toItem 与自定义侧对齐,已拥有安装记录的来源豁免
duplicate conflict,先装好的服务端插件不再被同名自定义来源降格
- sources: 缓存交换回滚失败不再吞错——staging 落位失败且旧缓存恢复
rename 也失败时抛出含备份路径的 INTERNAL 错误,便于定位手动恢复,
不再让旧缓存静默遗失在随机备份目录
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 自定义市场账户绑定收口、安装防篡改与缓存交换自愈
- service: 抽 runForOwner 统一闸,所有把市场数据返回 Renderer 的
owner-bound 读/管理路径(listSources/addSource/removeSource/
refreshSource/detail/customDetail)在返回前强制 generation 校验,
账号在 await 间隙漂移即拒绝,不再逐路径漏加;snapshot 聚合返回前
补最终校验。写路径 install/uninstall 保留「owner 变更后仍完成
已验证安装、账本归最初捕获者」的既有语义,不套用返回前拒绝。
- install: 安装防篡改改比实际打进包的 manifest(packed.manifest)
而非打包前磁盘上的 ghost.json,堵住前置比对通过后、打包读取
文件前目录被改(保持 id/version 却新增权限声明)的窗口;冗余的
前置逐字比对移除,前置仅保留清单合法性与保留 id 快速失败。
- sources: Git 缓存交换改用固定备份名(cloneDir.backup)并在
refreshSource/discoverSource/discoverAll 入口自愈——cloneDir 缺失
但备份存在时自动拉回固定路径,连续交换/恢复失败不再让有效缓存
脱离 cloneDir、来源持续不可用;交换+恢复连续失败的诊断(含 backup
绝对路径与原始 FS 错误)只进 main 日志,IPC 返回不含内部路径的
通用错误。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 自定义市场快照出口校验、原子写回滚与缓存交换并发防护
- service: snapshot 服务端 listAll 失败分支在返回前补 generation
校验——切号导致服务端请求失效时,不再把按旧账号发现的自定义
市场项返回当前会话;并标注捕获 owner 后每个 return 出口都须过闸
- utils: 新增 atomicWriteFileSync 可回滚原子写(temp + rename;
Windows EPERM/EEXIST 兜底改备份交换,先改名 .bak 再落位、失败从
.bak 恢复),store.ts(sources.v1.json)与 ledger.ts(ledger.v1.json)
统一换用,修掉兜底删目标后二次 rename 失败导致配置全丢的缺陷
- sources: Git 缓存交换加 .swapping 文件哨兵——交换瞬态(cloneDir
已改名 .backup、staging 未落位)与遗留故障同形,并发 discoverAll /
snapshot 的 recoverCloneCache 见新哨兵判定为进行中不抢占拉回,
仅无哨兵或哨兵过期(进程崩溃残留)才自愈,避免顶掉 staging 落位
目标并使回滚失效
Signed-off-by: xxxcc <xxxcc123@126.com>
* refactor(desktop): 自定义市场缓存改为版本目录+指针,并修复凭证与原子写
缓存交换反复出问题的根因是"原地交换固定路径 + 事后补救"的思路本身:
cloneDir 被搬来搬去,任何时刻都可能不存在或被占用,并发与失败都无法
干净处理。改为版本目录 + 当前指针,从结构上消除这一整类问题。
- sources: Git 缓存改为 sources/<slug>/versions/<version>/ + current
指针文件。刷新在新版本目录完成克隆/快进与完整发现验证,通过后经
可回滚原子写切换 current 指针,再清理旧版本;读取方永远经指针解析
到完整版本目录,不存在"固定路径被 rename"的瞬态,不再需要备份交换/
哨兵/自愈。旧布局(槽目录直接是缓存)首次读取自动迁移进版本目录。
- sources: 指针切换与删旧版本之间的 TOCTOU——读取方在版本目录放
.reading 标记,刷新方删旧版时跳过有活跃读者的目录,避免误删在读者。
- parse: 拒绝带查询参数的 Git URL(?access_token=SECRET 等签名/令牌
会明文持久化并回传 Renderer),引导走 credential helper / SSH。
- atomicWriteFile: 写入前若主文件缺失且 .bak 存在,先恢复 .bak 再写,
避免把缺失主文件读成空后覆盖唯一快照(实现注释承诺的自愈)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* refactor(desktop): 自定义市场缓存读取无锁化+延迟清理,拒绝明文 git:// 协议
刷新方不再主动删除旧版本目录,只做原子切换 current 指针;历史版本
统一由下次刷新成功后的延迟清理移除。读取路径(listSources/快照/安装
打包)因此零并发保护,消除了解析→使用窗口内误删在读者、以及 .reading
标记被 fs.cp 复制进新版本导致的泄漏与磁盘无界增长。
同时收紧 Git 传输:parse 拒绝无认证无加密的明文 git://,仅允许
https/ssh/git@。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 缓存清理引用保护、读侧备份恢复与安装冲突重算
自定义市场第 N 轮 review 的三条未 resolve 意见,按同一批修复:
1. 缓存清理会抽走在读者(P2)。刷新切完 current 指针后立即删除全部非 current
版本,包括"刚被切下来、正被发现/详情/安装打包使用"的那一代。改为进程内
引用计数保护:resolveCurrentVersionSync 全同步,acquireCurrentVersion 在同
一个同步块内解析指针并登记引用,中间无 await —— 不存在"解析到登记"的窗口
(文件系统标记做不到这点,标记还会被 fs.cp 复制进新版本)。清理只删引用数
为 0 的目录,仍被引用的推迟到最后一个引用释放。自定义源安装改走
withDiscoveredSource,租约一直持到 .cindy 打包结束。
顺带修同类缺陷:git clone 会在目标同级建 `<dest>.staging-*`,原先目标在
versions/ 内,并发刷新的清理会删掉别人正在写入的 staging 目录。改为在途
工作一律落在 versions/ 之外的 incoming/,验证通过才 rename 进 versions/,
versions/ 只承载完整版本;incoming/ 残骸由下次刷新按在途集合前缀清理。
2. 备份只在写侧恢复,数据仍会永久丢失(P1)。主文件缺失时 ledger.read() /
MarketSourceStore.read() 先把它读成空数据,调用方拿空数据发起写入,写侧
刚恢复的 .bak 随即被空快照覆盖。新增 readAtomicFileSync,在读取入口恢复
.bak 再解析,两个 reader 与 current 指针读取统一走它。
3. 安装入口不重算 ghostId 冲突(P1)。列表按"服务端目录 + 全部自定义来源"
合并计数标 conflict 并禁用,但安装只在选定来源内查插件:不可信 Renderer
直接调用 IPC,或详情打开后另一窗口添加了同 ghostId 的来源,都能把标记为
冲突的插件装进来。安装在产生副作用前用同一口径重算所有权,服务端不可用
时降级为仅自定义来源。
合并 upstream/main 时把 expectedManifest 与新的 expectedInstalledApproval
并存,自定义源安装同步采用 diffInstalledGhostPermissionItems(不信任可变的
live manifest 作为已批准基线)。
验证:desktop main 1305 + renderer 6854 测试、typecheck、check:i18n、
check:i18n-glossary 全过。新增用例均验证过"去掉修复即失败"。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 租约注册表跨实例共享、激活后不回收版本、安装冲突同口径
本轮 4 条未 resolve review + 1 条折叠意见:
1. 租约跨实例失效(P1,Greptile + Codex)。`PluginMarketService` 每次操作都
`new MarketSourceManager(...)`,而引用计数是实例字段 —— 安装 manager 持有
的租约对刷新 manager 完全不可见,刷新照样删掉正在被打包读取的旧版本,上一轮
加的引用计数跨操作等于没做。注册表(versionRefs / pendingDeletes /
activeIncoming)改为**模块级、按绝对路径为键**:受保护的对象本来就是文件系统
路径,按路径为键才是正确作用域;owner 隔离由 ownerScopedUserDataPath 编进
cloneRoot 的路径自带。租约测试改为读取与刷新走**两个不同的 manager 实例**,
与生产一致。
2. 激活后不得回收新版本(P2)。原 catch 覆盖了 activate + prune + store.update:
元数据写入失败(磁盘满/只读/文件锁)时指针已指向 newDir、旧版本已被清理,
却仍把 newDir 删掉 —— 留下指向不存在目录的指针,整个来源从列表消失。现在
可回滚的清理只覆盖"落位 + 激活"这一段,且删除前用指针再核对一次;激活之后
的失败只如实上报,绝不回收已生效的缓存。
3. 服务端安装入口同口径重算冲突(P2)。快照按"服务端目录 + 全部自定义来源"合并
计数标 conflict 并禁用,但 detail() 传空重复集合把它恢复成可安装,install()
也只统计服务端目录内部重名 —— 普通 UI 流程或直接 IPC 都能装上本该被冲突闸
拒绝的服务端项并抢占 ghostId。抽出 crossSourceDuplicateGhostIds /
assertSourceOwnsGhostId,服务端与自定义两条安装路径、两个详情入口共用同一
口径,已拥有安装记录的来源保留所有权。
4. sparse 克隆不再留 detached HEAD(Copilot 折叠意见)。无显式 ref 时原先
`checkout HEAD`,导致刷新的 `git pull --ff-only` 稳定失败并回落整仓重克隆 ——
sparse 源每次刷新都全量 clone,快进路径实际是死代码。改为解析 origin/HEAD
取默认分支并 checkout 该分支,保留跟踪关系;解析失败才退回 HEAD。
合并 upstream/main:upstream 已 revert makecindy#1080(批准 receipt 改造),因此
expectedInstalledApproval / ghostInstallApprovalToken /
diffInstalledGhostPermissionItems / forbiddenRootDirs /
resolveThroughExistingAncestor / onReapprove 全部不再存在,上一轮为并存而保留
的这些引用同步撤掉,自定义源安装的权限对比回到 diffGhostPermissionItems。
存量插件影响:无。改的是市场克隆缓存布局(versions/ + current 指针 + incoming/),
不触及已安装插件的包目录、批准状态与账本 schema;旧布局(槽目录直接是缓存)由
resolveCurrentVersionSync 自动迁移进版本目录,升级后用户无需任何操作,已装、
已批准、已启用的插件照旧可用。基于旧布局的升级用例见 sources-manager.test.ts
的 legacy 迁移用例。
验证:desktop main(plugin-market 149 全过)、renderer 6840 全过、typecheck、
check:i18n、check:i18n-glossary 通过。新增 5 个用例均验证过"去掉修复即失败"。
scriptRunnerPythonProtocol 与 makerSendToSessionOrdering 的 6 个失败在移除本次
改动后同样存在(来自合并进来的 upstream/main),与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* refactor(desktop): 市场缓存删除收口到唯一入口,结构上排除误删在读路径
Greptile 这轮点的是 `removeSource` 整槽递归删绕过租约(并发的详情发现/快照/安装
打包正持有槽内版本目录时会读到一半 ENOENT)。这条成立,但更该修的是它反复出现的
形态:清点下来 `sources/index.ts` 有 **9 处删除点,只有 3 处查了租约**。租约是
"可选的",每加一个删除点就得记得查一次,所以每轮 review 都能再找出一个漏掉的
(交换旧目录 → 清理历史版本 → 清理暂存目录 → 移除来源 → 失败回滚)。这次不补第
7 处,改成让这类错误无法再写出来。
**收口**:新增 `sources/cacheLease.ts`,作为市场缓存路径的租约登记与**唯一删除
入口**。判据只有一条——目标路径与任何持有中的租约**在任一方向上重叠**就不删,
推迟到最后一个相关租约释放,执行前再过一次 `skipIf`(例如"该版本又成了 current"
就放弃)。三种历史事故都落在这一条里:
- 清理删掉正在被读的版本目录 → 租约 === 目标
- 移除来源整槽递归删而槽内版本在读 → 租约在目标之下(本轮 Greptile)
- 清理删掉 git 的 `<dest>.staging-*` → 目标在租约"之下"(字符串前缀)
`sources/index.ts` 的 9 处删除点全部改走 `removeCacheDir` → `removeCachePath`,
文件内不再出现任何 `fs.rm`;各调用点的租约判断随之删掉(净减 120 行)。附带把
"快进失败后清理暂存目录再复用"改成**用另一个全新暂存目录**——清理现在要过守卫、
可能被推迟,复用会让 clone 写进脏目录;暂存回收统一到方法末尾一处。
**结构门禁**:新增 `sources-cache-lease.test.ts`,除守卫本身的行为用例外,断言
`sources/index.ts` **不出现任何直接文件系统删除**。行为用例只能覆盖已知路径,这条
门禁才是防止下一轮再冒出一个绕过点的东西——已实测:把 `removeSource` 退回裸删,
门禁与新增的行为用例同时失败(后者精确复现 Greptile 描述的 ENOENT)。
验证:desktop main 1264(plugin-market 157)全过、typecheck、check:i18n、
check:i18n-glossary 通过。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 备份恢复失败拒绝写入、Git stderr 路径脱敏、指南示例注释本地化
Codex 针对 4e33fb6 的 3 条 P1(第 4 条 removeSource 与安装并发已在 188d5bb
的删除收口中修掉):
1. `.bak` 恢复失败被当成恢复成功(数据永久丢失)。`restoreBackupIfMainMissing`
的 catch 吞掉 rename 失败后仍返回 true:读取方据此读成空数据 → 调用方拿空数据
发起写入 → 主文件出现 → **下一次写入把仍然有效的 .bak 当陈旧残留删掉**。改为
区分"发现备份"与"恢复成功",恢复不了就抛 `AtomicBackupUnrecoverableError`,
读写两侧一致拒绝继续;两个 reader 的 catch-all 显式放行该错误,不再降级成空
账本/空来源表。宁可这次操作失败让上层看见,也不能把唯一副本换成派生自空数据
的内容。
2. Git stderr 把宿主绝对路径泄露给 Renderer。原 sanitizer 只替换已知缓存路径与
URL 内嵌凭证,而 OpenSSH、credential helper、杀毒钩子会往 stderr 写
`/Users/<name>/.ssh/...`、`C:\Users\<name>\...`、`~/.gitconfig` —— 用户名与
宿主目录结构不该经 IPC 到达 Renderer。新增通用绝对路径脱敏(POSIX 至少两段、
Windows 盘符与 UNC、`~/` 开头),完整原文仍只留在 main 日志。
3. 指南结构树里的解释性注释硬编码中文。英/日/韩界面下展示与复制出来的内容都夹着
"清单/插件身份卡/插件入口"。三个标签改为 locale key(四语言齐备),展示与复制
共用同一份已本地化的树;路径本身仍不翻译,路径列宽固定所以箭头对齐不受标签
长度影响。
验证:desktop main 1267、renderer 6842 全过;typecheck、check:i18n(6250 key
四语言一致)、check:i18n-glossary 通过。新增 4 个用例逐个验证过"退回修复即失败"。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 延迟删除再核对、默认安装与提交点冲突复核、URL 令牌与短路径脱敏
本轮 7 条 review 一次性修完(按要求先攒完再提交,不边提交边改):
1. 延迟整槽删除会删掉重新添加的来源(Greptile + Codex,**我上一个 commit 引入的
回归**)。删除收口后 `removeSource` 的整槽删除会因租约推迟,而用户完全可以在
推迟期间移除后立刻重新添加同名同源——槽路径只由 (name, source) 派生,同一个槽
被复用并激活了新版本;那笔延迟删除执行时不核对,会把刚添加成功的缓存连 current
指针一起删掉。新增 `slotIsConfigured` 作为执行前的再核对,`removeSource` 与
`commitDiscoveredSource` 两处整槽删除都带上;按"槽是否仍被配置占用"判定,同时
覆盖重新添加同源(同槽,放弃删除)与异源(异槽,旧槽照常回收)。
2. 默认安装先于跨来源冲突判定(Codex P1)。`applyDefaultInstalls()` 只按服务端目录
判重,与自定义来源同 ghostId 的 defaultInstall 项会被自动下载、安装、启用,静默
抢占所有权,而列表随后才把它标成 conflict——既定事实已经发生。冲突集合改为在
默认安装**之前**算出并传入,命中的默认项直接跳过。
3. 所有权检查与提交之间仍有窗口(Codex P1)。入口检查之后,服务端安装还要下载数
分钟、自定义安装还要异步打包,期间另一窗口可经独立的 market-sources 互斥键添加
声明同一 ghostId 的来源。改为在真正改动 Ghost 运行时之前再算一次:服务端路径经
`installDetail` 新增的 `recheckOwnership`(复用已拉到的 catalog,不多打网络
请求),自定义路径经已可 await 的 `beforeCommit`。
4. `ssh://` 与 scp 形态未拒绝 query/fragment(Copilot)。`ssh://host/repo?token=`、
`git@host:repo.git#sig=` 都能把令牌持久化进来源配置并在 UI 摘要露出;https 也漏了
fragment。三种形态一并拒绝。
5. 短 POSIX 路径漏脱敏(Codex P1)。上一版正则要求两个带斜杠的段,实际只盖到三段
及以上,`/home/alice`、`/tmp/foo` 原样回传 Renderer。改为一段起即覆盖,并先把
URL 摘出占位再还原,保住用户自己输入的仓库地址可读。
6. 切到本地来源后仍提交 Git 专属字段(Codex P1)。用户先按 Git 源填了 ref/稀疏路径
再改成本地目录时,那两个输入框已禁用、他无法自己清空,提交后被 Main 以
REF_NOT_ALLOWED_FOR_LOCAL / SPARSE_NOT_ALLOWED_FOR_LOCAL 拒绝,成为死结。
组装 payload 时按来源类型忽略这两个字段。
存量插件影响:无。只改市场缓存清理时机、安装期冲突判定、错误详情脱敏与来源输入
校验,不触及已安装插件的包目录、批准状态或账本 schema。
验证:desktop main 11038 通过 / renderer 6844 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 9 个用例**逐个验证过"退回修复即失败"**(其中 A、C
两条第一版没有牙齿——A 被 addSource 另排的带守卫删除掩盖,C 的竞争来源加得太早被
入口检查拦住——已改成直接写回配置与"第一次读取来源表之后才注入"两种精确时序)。
`makerSendToSessionOrdering` 与 `scriptRunnerPythonProtocol` 的 6 个失败在移除本次
改动后同样存在(来自合并进来的 upstream/main),已用 stash 取基线确认,与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 非 ENOENT 读取错误上抛、在途删除可等待、切号清空来源摘要
轮询到 Codex 3 条 P1,一次性修完(提交前又查过一次 review,无更新):
1. 非 ENOENT 读取错误被当成"文件不存在"(P1,数据丢失)。`readAtomicFileSync` 的
catch 无条件返回 null:`sources.v1.json` / `ledger.v1.json` 明明在,只是被
Windows 文件锁、权限或瞬时 I/O 挡住时,两个调用方都会解释成空状态,同一次
添加/更新/账本写入随即用空状态派生的快照覆盖原文件,其余来源与安装记录永久
丢失。现在只有 ENOENT 算"不存在",其余读取错误一律上抛。
两个 reader 相应重构:读取与解析分开——IO 错误(含备份救不回来)直接上抛,
只有"内容确实不是合法 JSON"才降级重建。原先那个 catch-all 会把新抛的错重新
吞成空数据,等于白改。
2. 在途删除无法等待(P1,**cacheLease 的残留漏洞,我引入的**)。`drainDeferred`
里是 `void fs.promises.rm(...)` 发射即忘,且队列项在 rm 完成前就被移除:
`skipIf` 只在启动前核对一次,一旦 rm 开始跑就再也拦不住。移除来源后立刻重新
添加同名同源时,旧槽删除可能正在途中,新版本落进同一个槽后被它顺手带走,
留下有效配置但来源持续报缓存缺失。新增 `inFlight` 登记与
`settleCachePathRemovals(path)`,复用槽的两个入口(添加、刷新)在写入前等待与
该路径重叠的在途删除结束。
3. 切号后仍展示上一账号的来源摘要(P1,隐私)。对话框保持打开时切号,加载 effect
不因 owner 变化重跑,读取失败时还明确保留旧列表;来源管理子对话框会继续把账号
A 的私有仓库 URL 与本地绝对路径展示给账号 B。Main 的返回前 generation 校验只
能拒掉新请求,撤不回 Renderer 已缓存的数据 —— 改为把 mode/dataOwnerId 作为
作用域键,一变就立刻清空来源状态、关掉子对话框,再按新账号重载。
存量插件影响:无。只改本地状态文件的读取错误语义、缓存删除的等待时序与对话框的
账号作用域,不触及已安装插件的包目录、批准状态或账本 schema。
验证:desktop main 11040 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 3 个用例均验证过"退回修复即失败";其中在途删除
那条第一版没有牙齿(最后等待 20ms 比模拟的 rm 40ms 还短,删除尚未落地就断言完了),
已把等待改到长于 rm 耗时。`makerSendToSessionOrdering` 与
`scriptRunnerPythonProtocol` 的 6 个失败为合并进来的 upstream/main 既有失败,
已用 stash 取基线确认,与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 槽复用持租约、指针可从备份恢复、清单拒穿越、提交段共享来源锁
轮询到 6 条 review,先修其中 5 条(第 6 条见下,需要产品决策才动):
1. 复用缓存槽期间尚未启动的延迟删除仍会启动(P1,**同一族的第三次,我的**)。
`settleCachePathRemovals` 只能等到**已登记在 inFlight** 的删除;移除来源留下的
那笔可能还躺在 deferred 里,会在 settle 返回后、`store.add` 之前因旧租约释放而
启动 —— 那时 `slotIsConfigured()` 仍是 false,于是它与紧随的 mkdir/rename 并发,
把刚重建的槽删掉。改为**整个复用段(清残骸 → 落位 → 切指针 → 写配置)对整个槽
持租约**:守卫因此无法启动这笔删除;配置在释放槽租约之前写入,释放时 skipIf
直接否决它,中间没有空档。
2. current 指针无法从备份恢复(P1)。指针也是原子写维护的文件,备份交换连续失败时
唯一有效的那份留在 `current.bak`,而解析只看主文件,于是把仍有完整版本的缓存判成
缺失,整个来源报 market root missing。改为经 `readAtomicFileSync` 读取(它会先
恢复备份)。指针读不出来仍按"无可用缓存"处理——缓存可重建,与 ledger/来源配置
不同,不存在"用空数据覆盖用户资产"的风险。
3. 清单读取跟随 symlink 可读到市场根目录外(Copilot)。恶意市场可以把
`.agents/plugins/marketplace.json` 做成指向根目录外的链接,借宿主之手读任意路径。
改为与 `resolvePluginDir` 同一套判据:realpath 后必须仍在市场根内,否则拒绝。
4. 安装提交段与来源变更缺互斥(P1)。`beforeCommit` 复核通过后,
`installOrUpdateMarketGhostPackage` 还要先 await 包检查才开始改动运行时,那段
时间另一窗口仍能添加声明同一 ghostId 的来源,复核结论在落位前过期。新增
`withCommitLock`,把"复核 + 落位"整段放进与来源增删共享的 `market-sources` 锁;
服务端路径同样把 recheck + 落位包进该锁。与按 pluginId 的安装锁嵌套无环,
不会死锁。
5. 切号后移除确认框仍存活(P1,隐私/误删)。父对话框关闭时 `pendingRemove` 不清空,
用户确认会对**当前账号**执行 removeSource;若新账号恰有同名来源,就会在仍显示旧
账号来源名的确认框里误删新账号的配置。改为父级一关就清空待移除项与忙碌态,
并让确认框的 open 受父级约束。
**未处理(需要决策)**:`ledger.ts` 的 `source` 枚举新增 `git-market`/`local-market`
后,回退到旧版本会被 `validRecord()` 过滤并重写落盘,自定义安装的溯源永久丢失
(再升级后该插件被投影成占用 ghostId 的本地冲突项、无法从原市场更新)。这命中新立的
存量兼容红线的**降级方向**,有多个可选设计(独立存储 / 旧版无损保留的编码),取舍
要产品定,不适合我单方面选。已在 thread 里说明现状与两个方案。
验证:desktop main 11045 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 5 个用例逐个验证过"退回修复即失败";其中槽复用那条
第一版只验证了 cacheLease 的机制、没验证调用点,已改成用 `fs.promises.rename` 做
时序钩子精确命中"settle 之后、store.add 之前"的窗口。
`makerSendToSessionOrdering` 与 `scriptRunnerPythonProtocol` 的 6 个失败为合并进来的
upstream/main 既有失败,与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 自定义溯源独立账本、来源指纹所有权、提交点来源核验、指针 .bak 补盲
本轮 5 条 review 一次性修完(方案 A 经产品确认):
1. 自定义安装溯源移入独立账本(P1,存量兼容红线·降级方向,方案 A)。旧版
`validRecord()` 是封闭枚举,`git-market`/`local-market` 记录在降级后的任意一次
写入中被过滤并重写落盘,溯源永久丢失,再升级后插件被投影成"占用 ghostId 的
本地冲突项"。现在自定义溯源写入 `custom-ledger.v1.json`(与主账本同目录、同
owner 作用域),旧版本不认识也不会触碰它;主账本只出现旧版认识的 source。
`PluginMarketLedger` 对外 API 不变,读取合并两个文件;早期开发版混写进主账本的
存量在任意一次写入时按 source 自动归位。
2. 来源指纹纳入所有权(P1,安全)。市场名是 marketplace.json 自报的、可复用:
移除来源 A 后添加同名来源 B,`customMarketPluginId` 完全相同,所有权只锚
pluginId 会让无关甚至恶意仓库借同名市场"更新"A 装出来的插件。新增
`marketSourceKey(source)`(与 sourcesEqual / marketCloneSlug 同一套判定维度,
后者改为复用同一定义),自定义安装记录写入 `sourceKey`,customInstall 与列表
投影(customToItem)的所有权判定都要求 pluginId 与指纹**同时**对上;指纹存放在
独立账本里,不受旧版 schema 约束。
3. 提交点确认来源仍在(P1)。移除来源会先拿 SOURCE_MUTATION_KEY 删配置,租约只
保住了目录字节;`assertSourceOwnsGhostId` 只查重复 ghostId,从不确认
ref.marketName 仍指向最初发现的来源——没有竞争项时安装照样落位,写下一条没有
对应来源的孤儿账本记录。`beforeCommit`(在提交锁内)新增核验:来源必须仍存在
且指纹与入口发现时一致,否则 PRECONDITION_FAILED。
4+5. 指针读取的两处 `.bak` 盲区(Copilot,防御纵深)。`isCurrentVersionDir` 与
`pruneStaleVersions` 仍在裸读 current 主文件:Windows 备份交换失败后唯一有效
指针留在 current.bak 时,前者把当前生效版本误判成"非 current"、让推迟删除把它
删掉,后者把"有 current"误判成"无"。都改走 `readAtomicFileSync`;读不出指针时
fail-safe——isCurrentVersionDir 按"是 current"处理(宁可不删),prune 直接放弃
本轮清理。
存量插件影响:无(本修复正是为守住降级方向的存量红线)。自定义市场特性尚未随
任何版本发布,`custom-ledger.v1.json` 是新文件;主账本 schema 与旧版本完全一致。
验证:desktop main 11051 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 7 个用例(独立账本 ×3:主账本无自定义 source、
模拟旧版重写后溯源存活、混写存量归位;同名异源拒更新且列表标 conflict;打包期间
来源被移除则拒装;current.bak 下推迟删除不误删当前版本;update-available 夹具补
指纹),逐个验证过"退回修复即失败"。`makerSendToSessionOrdering` 与
`scriptRunnerPythonProtocol` 的 6 个失败为合并进来的 upstream/main 既有失败,
与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 按规则文档全面自查——detail 脱敏、清单资源上限、名称边界、指纹防碰撞
按 docs/dev-rules 对 PR 全改动面做主动排查(electron-security / credentials /
plugin-security / media-storage / engineering-conventions 逐清单对照),一次性修掉
排查发现的问题与随后进来的 2 条 review(与排查结果重合):
1. discover 的错误 detail 把宿主绝对路径直达 Renderer(与已修的 git stderr 同族)。
realpath / readFile 失败的 message 自带完整路径,经 throwIpcError 原样转发。
导出 git.ts 的 redactAbsolutePaths 供 discover 共用,两处 catch 的 detail 均经
脱敏并截断到 256 字符。
2. 清单与身份卡无资源上限(Codex 同日也点了此条)。市场仓库是不受信内容,git clone
不限单文件大小:数 GB 的 marketplace.json / ghost.json 会在 readFile + JSON.parse
里耗尽 main 进程内存;十万条目的清单会把快照/列表/刷新拖死(每条目 realpath +
读身份卡)。新增:清单须为普通文件且 ≤1MiB、插件条目 ≤512(超限拒绝);
ghost.json ≤512KiB(超限按条目非法跳过,不拖垮市场)——都在 readFile **之前**
按 stat 判定。
3. 市场名与 displayName 无边界。名称进 store 持久化、进 pluginId、进 UI 与路径
slug:超长导致配置无界膨胀,控制字符/双向文本控制符可伪装 UI 文案与污染日志。
名称 >128 字符或含禁用字符拒绝整个市场;displayName 是描述性字段,非法只置
null 不拒市场。
4. marketSourceKey 拼接式指纹存在可构造碰撞(Copilot 点出 sparsePaths 的
join(',');自查发现 ref 含 ':' 同样可碰撞:ref:'x'+['p'] 与 ref:'x:p'+[] 同
key)。而这个 key 承担账本所有权判定,碰撞 = 同名异源防线失效。改为 JSON 数组
序列化(无歧义定界);marketCloneSlug 复用同一定义随之修正。特性未发布,
slug/指纹变化无存量影响。
5. 防御性说明加固:SOURCE_MUTATION_KEY 写明锁次序不变量(pluginId 内可取来源锁、
反向禁止)与"刷新持锁期间提交段排队"的有意权衡;cacheLease 写明字符串比较大小写
敏感的适用前提(所有路径同源派生)。
6. 按 plugin-security-and-authoring.md 的 PR 义务补齐描述:基座确认门声明(本 PR
命中白名单,需把关人明确 Approve)、FORGE_GUIDE 无需同步及理由、device-link
不登记及理由、媒体总仓与凭证落盘的对照结论。
排查中确认无问题的项(记录以免复查):forge 打包 walk 只认 isFile/isDirectory,
symlink 条目不进包(无逃逸);新增 6 个 IPC 错误码在 renderer errorKey 全覆盖;
displayName 未被任何投影消费、不出 main;临时文件均在 temp 且双路径清理;锁图无环。
存量插件影响:无(名称/大小上限只作用于尚未发布的自定义市场内容)。
验证:desktop main 1296(plugin-market 187)/ renderer 101 全过、typecheck、
check:i18n、check:i18n-glossary 通过。新增 7 个用例逐个验证过"退回修复即失败";
其中 detail 脱敏与 ghost.json 大小两条的第一版没有牙齿(悬空 symlink 走不到目标
catch、超大身份卡本来就过不了校验),已分别改成 mock realpath 定向触发与
"断言未发生 readFile"。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 身份卡 lstat 拒 symlink,账本合并按状态与时间消解同 ghostId 冲突
轮询到的 2 条 P1,随同步 upstream/main 一并处理:
1. ghost.json 自身可以是 symlink(P1,DoS + 投影外部内容)。realpath 只校验了插件
目录,身份卡文件仍可指向市场外——stat 跟随链接,`/dev/zero` 这类特殊文件
size 为 0 还能绕过大小闸,readFile 会无限读到 OOM;指向普通外部 JSON 时会被
解析并投影给 Renderer。改用 lstat(不跟随)并要求普通文件:目录已 realpath,
最后一段拒掉 symlink 后链接无处藏身,与打包侧"符号链接一律不穿透"同口径。
清单侧无需改:realpath(manifestPath) 已解析链接并做根包含校验,根外目标
(含 /dev/zero)在包含性判定即被拒。
2. 账本合并让陈旧自定义记录覆盖服务端记录(P1,降级窗口)。降级后旧版卸载自定义
安装并从服务端装同 ghostId(只写主账本),custom-ledger 里留着旧版不认识、也
不会清理的陈旧记录;升级回来后无条件的 custom 覆盖会把服务端安装错误归属给
自定义来源并允许其提供更新。新增 preferRecord 消解:仍在安装中的优先于已卸载
的,同态按 updatedAt 新者胜,完全平手保主账本(冲突本身意味着旧版操作过主
账本);胜出后由任意一次写入按 source 归位,败方随整份重写清掉。
同步 upstream/main(c1f3c88,51 个 commit,零冲突),带入 makecindy#1267 的 i18n 修复
(上一轮 verify 40 秒即挂正是 check:i18n 阶段)。
存量插件影响:无。
验证:desktop main(plugin-market 189)/ renderer 6931 全过、typecheck、
check:i18n(6287 key 四语言一致)、check:i18n-glossary 通过。新增 2 个用例均验证
过"退回修复即失败"——symlink 用例指向的是市场外一份**内容合法**的身份卡,退回
lstat 后它会被接受并投影('stolen' 出现在插件列表),只断言"被跳过"没有这个区分度。
makerSendToSessionOrdering 与 scriptRunnerPythonProtocol 的 6 个失败为本地环境差异
(CI verify 曾全量通过),与本 PR 无关。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): manifest 摘要绑定所有权、异常出口校验代际、清理未用导入
本轮 3 条 review(Codex ×2 P1 + Copilot ×1):
1. 降级期间被本地替换的包不得被陈旧自定义记录认领(P1)。新版装自定义 A →
降级 → 旧版卸载 A 并从本地 .cindy 装同 ghostId 的 B → 升级回来:旧版不认识
custom 账本,记录原样留存(installed:true、pluginId/sourceKey 全对),B 会被
错误归属给 A 的来源并允许其更新覆盖。账本记录新增 manifestDigest(安装落位
那一刻的 manifest 规范化摘要,递归键排序后 sha256,与键序/来源无关);
customInstall 与列表投影的所有权判定升级为 pluginId + sourceKey +
manifestDigest **三者同时**对上——运行时的 manifest 不再是安装时那份,认领
即失效,fail 向 conflict(交还用户处置)。字段存于独立 custom 账本,不受旧版
schema 约束。
2. runForOwner 的异常出口不校验账户代际(P1)。operation 抛出时跳过
requireSameMarketOwner,git/discover 类错误的 detail 刻意保留了仓库地址等
上一账号私有信息,切号后的 Renderer 能看到。catch 里同样先校验代际,漂移时
统一替换成 PRECONDITION_FAILED——原始失败对当前会话没有意义。
3. sources/store.ts 留了一个未使用的 isAtomicBackupUnrecoverable 导入(上一轮
重构读取路径时遗留),删除。
存量插件影响:无(manifestDigest 只作用于尚未发布的自定义安装记录)。
验证:desktop main(plugin-market 200)/ renderer plugin 101、typecheck、
check:i18n、check:i18n-glossary 通过。新增 2 个用例均验证过"退回修复即失败"
(替换包用例退回后精确报 update-available、代际用例退回后泄出原始
MARKET_SOURCE_INVALID)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 显式 Git ref 用 --detach 消歧,防同名文件劫持固定引用
Codex 实测核对的真实固定引用失效:`git checkout <ref>` 在 ref 不存在、但仓库根
恰有同名文件时会退化成**路径检出**并成功返回,HEAD 仍停在默认分支——用户 pin 住
tag/commit,实际安装的却是默认分支内容(供应链完整性问题)。plain 与 sparse 两条
克隆路径的显式 ref 一律改为 `checkout --detach <ref>`:强制按 commit-ish 解析,
解析不了就如实失败(MARKET_REF_NOT_FOUND 分类照旧)。无 ref 时保持"checkout
默认分支"的既有行为(那是为了刷新可快进,不受此歧义影响);刷新的 pin ref 路径
本就走 fetch + reset --hard FETCH_HEAD,无歧义,不改。
同轮另两条(切号后表单草稿残留、确认框残留)判定为非必要修改,在 thread 里说明
理由后 resolve,不改代码:实际副作用(安装)已被 busy lease 与 Main 侧代际校验
挡住,残留的是当前设备同一操作者自己刚输入/查看的瞬时内容,与已修的"上一账号
持久数据泄露"不同性质。
存量插件影响:无。
验证:plugin-market 191 全过、typecheck 过。--detach 断言直接写进两条既有用例,
退回即失败。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): locale 无关的安装摘要,目录不完整时禁自动安装与所有权提交
本轮 2 条 Codex P1,核实后都不属于"评论关闭"档:
1. manifest 摘要受界面语言影响(P1,**上一轮 manifestDigest 的回归**)。核实
GhostManager.list() 返回的 manifest 确实是按当前语言本地化后的
(readInstalledLocalizedManifest)——上一轮用它算摘要,用户装完切换应用语言,
自装插件全部被误判成"包被替换":列表 conflict、更新被 ALREADY_EXISTS 拒。
摘要两侧统一改为 locale 无关输入:写入侧用发现层的原始 manifest(校验器
输出);比对侧从安装目录重读原始 ghost.json、过同一 validateGhostManifest 再
规范化(读不出/校验不过 → null → 不认领,fail 向 conflict)。投影侧的摘要在
localInstallSnapshot 一次快照只读一遍盘。
2. 来源发现失败被当成"目录为空"(P1)。任一来源因缓存指针/网络盘/本地目录暂时
不可读而失败时,容错枚举把它跳过,combinedDuplicateGhostIds 少了它声明的
ghostId——defaultInstall 会自动装上重名的服务端插件,手动安装的所有权断言也
放行,坏来源恢复前"先装先得"被抢占。把"目录不完整"与"来源确实为空"分开:
discoverCustomEntriesSafe 返回完整性;不完整时跳过全部默认安装(记日志),
assertSourceOwnsGhostId fail closed(PRECONDITION_FAILED,提示先刷新或移除
坏来源)。展示路径照旧容错,单源失败不拖垮列表。
存量插件影响:无。
验证:plugin-market 202 / renderer plugin 101 全过、typecheck 过。3 个新/改用例
均验证过"退回修复即失败"(update-available 夹具改为"目录放原始 ghost.json +
运行时挂本地化变体",退回 T2 后它精确失败——这正是切语言场景)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 打包快照防 TOCTOU、本地目录走原生选择器授权、账本丢失可收养自愈
本轮 4 条 Codex,修 3 条、1 条经判定评论关闭(见 PR thread):
1. 打包 TOCTOU(P1)。buildGhostPackage 校验 ghost.json 后,生成 zip 时**逐文件
重读磁盘**——并发改写(保 id/version、偷加权限声明)会让"返回给安装侧做审阅
比对的 manifest"与"包里的 manifest"分叉,自称的防篡改防线被打穿。第一次读到
的原始字节留作不可变快照,写入 zip 的 ghost.json 一律用它:校验的 = 返回的 =
包里的,三者恒等。
2. Renderer 直传本地路径不构成授权(P1,electron-security 规则)。XSS 控制下的
Renderer 可提交任意绝对路径让 Main 读取并允许打包安装;frame 校验只证明来源
窗口。本地目录改走 Main 原生目录选择器:新增 plugin-market:pick-local-source
(dialog.showOpenDialog,用户的选择即授权;Renderer 传的路径只作初始定位提示),
add-source IPC 对解析为 local 的来源一律拒绝。Git 源输入流程不变。
3. 账本写失败后市场链路死结(P2 实质)。包已落位但 custom-ledger 写入失败(文件锁/
磁盘错)时,记录缺失 → 快照永久 conflict(UI 禁用安装,连重试按钮都没有)、更新
永被 ALREADY_EXISTS 拒。新增"收养"自愈:运行时已装内容的原始 manifest 摘要与
本来源候选**完全一致**时,列表投影成可安装、customInstall 放行完整重装并补写
溯源。安全性:收养走完整重装(落位字节来自本来源的包)+ 用户刚审阅过同一份
manifest;声明有任何差异收养不了(与降级替换防线不冲突)。
4. "整包内容摘要"意见判定为评论关闭:manifest 完全等价时替换包的对外声明就是
同一插件同一版本,边际极低;完整解(安装 receipt)归属 makecindy#636 重做的正式跟踪
(docs/dev-rules/plugin-security-and-authoring.md §7),且本次的收养机制要求
重装落位,来源更新覆盖的字节始终来自来源包。
存量插件影响:无。手册(FORGE_GUIDE)无需同步:打包限制与作者可见契约未变,
快照写入是实现细节。
验证:desktop main 1341(plugin-market 200 / cindy-brain 52)/ renderer plugin 101
全过、typecheck、check:i18n 通过。3 个新用例逐个验证过"退回修复即失败"(TOCTOU
用例退回后精确显示偷加的 network 权限被打进包)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 选择器跨切号固定 owner,身份卡校验与读取共用同一文件句柄
本轮 2 条 Codex P1,都是对上一 commit 新代码的精确跟进,都修:
1. 目录选择器打开期间切号(P1)。原生框可以开着很久,owner 在用户选完后才捕获——
账户 A 发起的选择会被持久化进此时已激活的账户 B,绕过既有代际校验。改为打开
选择器**之前** captureMarketOwner,返回后 requireSameMarketOwner 同代际校验,
漂移即拒(PRECONDITION_FAILED),再让 addSource 在同一 owner 下落盘。
2. lstat 与 readFile 是两次独立打开(P1)。并发方可在 lstat 通过后、readFile 打开
前把 ghost.json 换成超大文件或指向 /dev/zero 的链接,绕过类型与大小闸。改为
单一文件句柄完成全链:open(O_RDONLY | O_NOFOLLOW,Windows 无此 flag 按 0,
NTFS 链接需管理员权限) → handle.stat 校验普通文件与大小 → 同句柄限量读——
检查与读取作用于同一 inode,路径再被替换也影响不到它。清单侧同样句柄化
(不加 NOFOLLOW:realpath 已解析链接并做过根包含校验,根内链接是允许的)。
存量插件影响:无。
验证:desktop main 1343 全过、typecheck 过。2 个新用例 + 1 条结构断言
(ghost.json 不得再经路径式 lstat/readFile)均验证过"退回修复即失败"。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 市场身份卡读取统一走单句柄限量闸,补 Windows 无 O_NOFOLLOW 回退
两条 review 意见,同一类缺口的两个残留面:
- Codex P1(install.ts / forge.ts):发现层句柄闸修好后,安装前置校验与打包
的清单读取仍在按路径无界 readFile。详情展示后、确认安装前,本地市场目录
是用户可写的活目录,把 ghost.json 换成超大文件或 /dev/zero 链接即可绕过
发现层的类型与大小闸,卡死或耗尽 main。
- Copilot(discover.ts):`O_NOFOLLOW ?? 0` 在无该 flag 的平台(Windows)上
退化为跟随符号链接,丢掉了上一版 lstat 闸的链接拒绝语义。
修法:抽公共工具 readBoundedFileNoFollow(main/utils/readBoundedFile.ts),
单句柄完成"拒符号链接 → 校验普通文件与大小 → 限量读取";无 O_NOFOLLOW 时
回退为 open 后 lstat 路径拒链接 + dev/ino 比对句柄,语义与 POSIX 侧一致。
发现(discover)、安装前置(install)、打包快照(forge)三条链路全部接入,
install.ts 加结构守卫:源码不允许再出现按路径的 readFile。
存量插件影响:无。只收紧不可信市场目录的读取边界;合法身份卡(普通文件、
≤512KiB)行为不变,已装插件不受影响。forge 打包侧对"ghost.json 本身是符号
链接"的拒绝与既有"符号链接一律不穿透"口径一致。
验证:desktop typecheck 过;main 套件 11357 过(6 个既有基线失败与本改动无
关);新增 7 个用例全部验过牙(逐个回退修复,对应用例确实失败)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 打包管道全量接入单句柄限量闸,读满循环消除网络盘短读截断
上一轮只给 ghost.json 接了句柄闸,本轮把同类缺口一次封死,不再逐面补:
- Codex P1(forge.ts zip 阶段):walk 通过大小预估后,文件仍可在 zip 逐文件
readFile 前被并发换成超大文件或 /dev/zero 链接,无界字节先于整包体积校验
进入 JSZip。现 zip 逐文件走 readBoundedFileNoFollow,按**剩余总预算**限量,
任何并发改动(换链接/删除/膨胀)结构化拒绝(TOO_LARGE)。
- 同类一并接闸:SKILL.md 校验读取(原读后才验大小)、目录 locale 校验
(原 statSync+readFileSync 两次打开,新增同步变体 readBoundedFileNoFollowSync)。
- Codex P2(readBoundedFile.ts):单次 FileHandle.read 在网络盘/FUSE 可能短读,
合法文件被截断成解析失败。抽 readToLength 读满循环,异步/同步/跟链接三个
变体共用;discover 清单侧同步收编为 readBoundedFileFollowLinks。
- 结构守卫:forge.ts 与 ghostLocaleFiles.ts 源码出现按路径 readFile/readFileSync
即测试失败,与 install.ts 的守卫同口径,防这一类第三次回潮。
存量插件影响:无。只收紧不可信目录读取边界;合法插件目录(普通文件、限内)
打包与校验行为不变,已装插件不受影响。
验证:desktop typecheck 过;main 套件 11362 过(6 个既有基线失败无关);新增
用例逐个验牙(退回读满循环/zip 预算闸/同步回退闸,对应用例确实失败,结构守卫
对 zip 退回同样咬住)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 全面梳理封口——不可信目录读取/中间链接/IPC 脱敏/装入竞态一次修净
不再逐轮跟单条意见:用 reviewer 的口径把整个 PR 面完整过一遍,把同类的所有
残留缺口一次修完。含两条挂起意见 + 四镜头审计(并发窗口/IPC 脱敏/Windows
语义/不可信读取)确认成立的项。
单句柄限量闸补全(readBoundedFile.ts 加 containWithin 根内复核 + 同步/跟链接
变体 + 读满循环):
- 中间目录 symlink 窗口:O_NOFOLLOW 只护最后一段,realpath 校验后父目录可被
换成根外链接。containWithin 在 open 后复核"stat dev/ino 与句柄一致且 realpath
仍在根内"。discover/install/forge 全部锚各自 realpath 根。
- 已安装目录裸读接闸(每次市场快照都跑):GhostManager list 的 ghost.json、
icon(statSync+readFileSync 两开→可越根读并把字节经 IPC 送 Renderer)、
.cindy-trust.json、locale;service.installedGhostRawManifestDigest。
- .cindy 源包 stat-then-read 两开 → 单句柄限量读。
IPC 脱敏 / 供应链:
- parse.ts:拒 URL 含反斜杠(WHATWG 把 \ 归一、git 按原样解析 authority →
凭证闸失明 + 连接 host 不一致);URL/sparsePaths 补控制符+bidi 闸;new URL
抛错改 fail-closed。
- git.ts:userinfo 整体掩码(单段 token insteadOf 重写场景 user:pass 规则盖不住);
sanitizeGitDetail 末尾剥控制符/bidi;加 longpaths/windowsHide/askpass 置空。
- install.ts:packed.message 与 validated.reason 经脱敏+截断再进 IPC。
- service.ts 展示投影:自定义插件 name/description/author 剥双向控制符(consent 欺骗)。
装入竞态 / 一致性:
- 市场装入通道钉 expectedPackageSha256(inspect 与 install/update 各自重读磁盘,
临时 .cindy 两读间被替换则前置校验全落在旧字节上)。
- customInstall beforeCommit 复核 runtime 所有权(不只来源):打包窗口内本地页
卸载→防"更新"降级为"首装+带电";窗口内新装同 id→防静默覆盖并绕过权限 diff。
- reconcileRemovedInstallations 的在场判定挪进 ledger 锁内即时重取(update 的
两次 rename 间的瞬态会被误判成卸载,永久写进 optOut)。
- forge:符号链接条目结构化拒绝(原静默丢弃→装出缺入口的包);mustExist 用
lstat 与收集侧同语义;临时包 0o600;node_modules 大小写不敏感跳过。
- customSourceNamesSafe 先 requireSameMarketOwner 再解析 owner 路径。
结构守卫扩到 service.ts(禁按路径 readFile/readFileSync)。存量插件影响:无,
只收紧不可信目录读取与不受信内容展示边界。
验证:desktop typecheck 过;main 11372 过(6 个既有基线失败与本改动无关);
新增/改动用例逐个验牙(containWithin/parse 反斜杠/git 掩码/git bidi/icon 链接/
投影 bidi/runtime 复核,退回对应修复均失败)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 收口四镜头审计余项——ghostId 互斥锁/装入边界/Windows 健壮性
延续"封类不逐点":把并发/Windows/边界三镜头审计里确认成立的余项一次修净,
含两条新挂起 P1(均针对上一 commit 引入的代码)。
装入并发(codex P1 + 审计 F1/F2/F3):
- 新增按 ghostId 的真正互斥锁 ghostInstallLock(GhostMutationCoordinator 只是
账户 drain barrier、可并发持有,不提供互斥)。市场装入、本地 .cindy 装入/
更新、卸载全部经它按 id 串行(不同 id 仍并行);市场路径的提交段(runtime
复核 + 落位)整体纳入,复核结论到落位之间同 id 的本地装入/卸载再也插不进来。
不可重入:只在最外层获取,installAndDock/manager.update 内部不获取。
- reconcileRemovedInstallations 的"是否仍在装"判定挪进 ledger 锁内即时重取
(update 两次 rename 间的瞬态不再被误判成卸载而永久写进 optOut)。
装入边界(greptile P1):
- install 前置:重解析 pluginDir 后要求仍等于发现时的规范路径。发现后目录或
父目录被换成指向市场外的链接(留同样 ghost.json)时 realpath 改变即拒,
payload 不进打包——清单摘要复核只比对清单本身,盖不住非清单文件的替换。
Windows / 跨平台健壮性:
- readBoundedFile:dev/ino 改 BigInt 比较并拒 0(NTFS FileId 截断误相等、
SMB/FUSE 填 0 会让回退闸退化);拒 0 时按不可信拒绝。
- atomicWriteFile:rename 白名单加 EACCES/EBUSY 短退避重试(Windows 真实瞬时锁);
成功落位后清理 .bak 失败不再把成功报成失败(rmSyncQuiet)。
- 目录 rename(add/refresh 落位、git clone 落位)瞬时锁重试;cacheLease 的 rm
带 maxRetries;cloneRoot 顶层 .incoming-*/.staging-* 残骸在新 add 前回收。
- 版本目录名比较改大小写不敏感(备份还原/跨 FS 迁移的大小写漂移不再误删
current);旧布局迁移用 lstat 拒符号链接槽,链接条目不搬。
- git 执行器:core.longpaths / windowsHide / GIT_ASKPASS,SSH_ASKPASS 置空。
结构门禁扩到整个 sources/(不只 index.ts);git.ts 的私有 staging 删除显式白名单。
存量插件影响:无。验证:desktop typecheck 过;main 11384 过(6 既有基线无关);
新增/改动用例逐个验牙(互斥串行/装入边界/dev-ino 拒 0/atomic 重试+清理不外抛)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): ghost 装入锁改可重入并收口到卡点;插件级读取失败改 fail closed
两条 P1 都是上一 commit 引入的**不对称**:我给相邻路径加了闸,漏了这两条。
这次按"卡点 + 可重入"封类,而不是再给眼前这条路补 guard。
服务端市场装入未进 ghost 锁(codex P1):
- withGhostInstallLock 改**可重入**(AsyncLocalStorage 记持有集合),从而能同时加在
三个卡点上而不自锁:installAndDock(ghostId 改必填,签名逼着新路径交出 id,锁自动
获取)、installOrUpdateMarketGhostPackage、uninstallGhostAndCleanup。新增装入路径
即使忘了取锁也被卡点兜住——这是本轮反复返工的根因(逐点补永远漏下一条)。
- 服务端市场提交段补取同一把锁,并把**账本写入一起纳入锁内**;自定义路径同样经新增
afterCommit 钩把溯源写入移进锁内。原先账本写在锁外:本地装入能插在"包已落位"与
"写下溯源"之间换掉同 id 的包,服务端记录不带 manifestDigest,投影时判不出来,
于是账本认领一个已被替换的包,后续市场更新静默覆盖本地安装。
- 锁序不变量写进注释:pluginId → SOURCE_MUTATION_KEY → ghostId → ledgerMutation。
卸载路径为 pluginId → ghostId → ledger(不取来源锁),与该偏序相容,无环。
- installAndDock 加纵深防御:落位包的 id 与加锁用的 id 不一致则 fail loud
(给错 id 等于锁上在错误的键上)。
插件级读取失败被当成内容非法(codex P1):
- resolvePluginDir 返回三态(ok / invalid / unreadable)。EACCES/EBUSY/EIO/ESTALE 等
可重试错误 = 事实不明 → 新增 unreadableCount,>0 让 discoverCustomEntriesSafe 判
complete=false,写路径 fail closed(与来源级失败同一口径,粒度到条目)。
- **刻意不含 ENOENT/ELOOP**:清单指向已删目录是常见永久错误,当成"暂时读不到"会让
这类市场永久阻塞默认安装;ELOOP 是拒符号链接的结果,属永久非法。附对照用例守住。
- skippedCount 此前是死数据(无人消费),现在与 unreadableCount 语义分明。
存量插件影响:无(只收紧并发边界与"事实不明时的写路径")。验证:typecheck 过;
main 11389 过(6 既有基线无关);4 组用例逐个验牙(去掉重入 → 3s 超时自锁;退回
服务端锁 → 外部同 id 请求在落位期间进入且看到账本未写;退回 unreadable 闸 →
默认安装照常下载)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 打包器不再自我参照,规范根由调用方贯穿传递
greptile P1:安装侧按 realPluginDir 复核完清单后,把原始 input.pluginDir 交给
打包器,打包器**自己再 realpath 一次并以此为 containWithin 锚点**。目录或其父目录
在两次解析之间被换成指向市场外的链接时,锚点就是那个外部目录,以它为准的包含性
判定自然全部通过,外部 payload 会被打包安装(外部目录只要留着同样的 ghost.json,
清单对账也发现不了)。
它建议的"改传 realPluginDir"不足以修好:此处两者字符串相等(上一 commit 已断言),
换个变量名关不上任何窗口。真正的缺口是**下游重新发明锚点**,所以修法是让锚点
贯穿传递、并由下游核对:
- buildGhostPackage 增加 expectedRealDir:传入时核对自己解析出的 realpath 仍等于
它,不等即 MANIFEST_INVALID。
- packGhostDirToFile 的 expectedRealDir **必填**(该路径的输入来自用户可写的市场
目录):签名逼着新调用方交出上游已校验的规范根,无法跳过、也无法自我参照。
packGhostDir(Forge 工具路径,用户自选目录)保持可选。
- install.ts 传 realPluginDir 同时作为打包输入与锚点;并把"规范根等值校验"移出
下面那个 try —— 它的 catch 会把一切压成"清单读不出来"的通用文案,这条原因
需要如实区分(新增 'The Plugin directory is unreadable' / 保留
'changed after discovery')。
中途替换仍由既有纵深防线兜住:逐文件读取的 containWithin 锚在同一个规范根上,
配合 dev/ino 复核,打包途中被换链接会让那些读取返回 null 并结构化拒绝。
存量插件影响:无。验证:typecheck 过;main 11392 过(6 既有基线无关);新增 3 个
用例并验牙(退回锚点校验 → 外部 payload 被打包、用例失败;锚点传错值 → 契约用例
失败;规范根一致的正常打包不误伤)。
Signed-off-by: xxxcc <xxxcc123@126.com>
* fix(desktop): 来源身份判据统一到 marketSourceKey;清单读取失败留 main 日志
维护者重档 auto-review 的 2 处 P1(均为纵深防御/可观测性,非安全漏洞):
- store.ts `sourcesEqual()` 用 `sparsePaths.join('\n')` 比较列表 —— 分隔符碰撞类
写法(`['a\nb']` 与 `['a','b']` join 结果相同 → 两个不同来源被判成同一个)。
改为直接复用 `marketSourceKey()`:它是本仓对"来源身份"的唯一定义(账本所有权
校验、缓存 slug 都用它),再写一套等价逻辑只会漂移。补碰撞对抗测试 + 一致性
测试(sourcesEqual 与 marketSourceKey 判断必须逐例相同)。
注:parse 层的 FORBIDDEN_SOURCE_CHARS 已拒掉换行、这条路实际走不通,但身份
判据不该依赖"上游恰好拦住了某个字符"这种远距离前提。
- install.ts 裸 catch 吞掉 TOCTOU 专用抛错。上一 commit 已把"规范根等值校验"移出
该 try(专用原因不再被覆盖),本次补齐另一半:catch 先 re-throw 已带 code 的
IpcError,再把原始 error 经脱敏写进 main 日志。Renderer 仍只拿通用文案,但事后
排查能区分"清单确实损坏"与"有人中途替换了插件目录"。
存量插件影响:无。验证:typecheck 过;main 11395 过(6 既有基线无关);4 个用例
逐个验牙(退回 join 写法 → 碰撞用例失败;退回裸 catch → 日志用例失败)。
Signed-off-by: xxxcc <xxxcc123@126.com>
---------
Signed-off-by: xxxcc <xxxcc123@126.com>
对照 issue makecindy#1243 验收清单补齐四项(前一 commit 已覆盖其余各项)。 ## 1. 本地包第三条恢复路径(验收第 3 条:不存在不可恢复状态) 迁移之后批准再丢失(receipt 损坏/被删,一次性门不再迁移)时,本地包用户原来只有 「重新提供原始 .cindy」一条死路。新增「从已装目录重新确认」: - `GhostManager.inspectInstalledReapproval(id)`:从安装目录读出确认卡事实(manifest + trust + 清单字节指纹),零副作用;`reapproveInstalled(id, {enable, expectedManifestSha256, expectedInstalledApproval})`:用户点过确认卡后开 receipt。 - **这是一次真实的用户确认**(全量权限清单逐条展示,无批准基线一律按新增项列), 与迁移的本质区别在此;与迁移的共同点:trust 走同一个封顶读取器、技能字节现算钉 指纹、只写状态根。来源记 `user-reapproval`。 - 两道与更新流程同形的绑定:`expectedManifestSha256` 钉住「确认卡展示的」与「批准的」 是同一份清单字节(确认间隙 ghost.json 被换 → 拒 state-changed); `expectedInstalledApproval` 防确认期间批准状态被并发改写,且已 approved 一律拒 (不给覆盖既有批准开口子)。 - IPC `ghosts:reapprove-inspect` / `ghosts:reapprove-installed` + preload + 类型; renderer `reapproveInstalledGhost()` 接进 handleReapprove 的本地包路线,安装目录 读不出清单时自动回退到原「重新选包」流程(目录坏了才需要找文件)。 - 随包插件一律拒走此路(见第 3 项);trust 镜像自称 cindy-official 一律封顶拒收 —— 官方档只该由 provisioning 逐字节对账后授予,非随包目录出现官方档镜像本身不可信 (封顶实现在 readLegacyInstallTrust,迁移与本路径共用)。 ## 2. schema/编码 bump 的存量语义(验收第 4 条) - 新增回归用例:首轮迁移把 schemaVersion 过时(判 invalid)的 receipt 从安装目录 backfill 治愈,用户停用决定保留 —— 格式升级不落到用户重新确认。 - 规则文档 §3/§5 写明:**从 v2 起任何 bump 必须实现「按旧编码核对 → 原地升级」**; v1→v2 无专门读取器的原因(v1 从未随构建发布 + 一次性迁移效果覆盖)已注明, 不得再引用为豁免。 ## 3. 随包插件隔离态死路文案(issue 附带毛边 1) 随包插件批准态异常时,原文案「请重新选择安装包」是死路(随包没有 .cindy 可选)。 - `InstalledGhost` 新增 `builtin` 投影(main 按种子清单判,renderer 不自判前缀 —— 企业种子不带 cindy- 前缀); - 详情页说明卡换 `bodyBuiltinRestart`(「重启 Cindy 即可自动恢复」,四语),列表卡与 详情页的「重新确认权限」按钮对随包隐藏,handleReapprove 双保险拦截。 ## 4. 常驻随包插件从隔离自愈后同会话补点火(issue 附带毛边 2) makecindy#1080 原判「批准自愈不自动重启」的理由是缺少"用户此刻想让它跑"的信号。本次收窄 范围后补上:只对**本会话内因撤销批准被我们熄灯**的常驻声明插件,在批准自愈那一轮 恢复点火 —— 重启只是恢复熄灯前的既有状态,不是新决策(常驻声明本身就是"启用即应 运行",启动扫描无条件点火)。点火仍走 spawnIfResident(自查启用态与会话可用性, 停用的不会被点亮)。其余情况维持不自动重启。 ## 回归覆盖 GhostManager.test.ts 新增 6 条:恢复路径 happy path(含 .disabled 镜像同步)、确认 间隙换清单被拒、随包拒走人工确认、已 approved 拒重复确认、official 镜像封顶、 首轮迁移治愈损坏 receipt。renderer 侧 builtin 字段补进现有夹具。 ## 验证 pnpm --filter desktop typecheck PASS;定向 117 files / 1507 tests PASS(main cindy-brain + plugin-market + renderer 插件面);pnpm check:i18n / check:i18n-glossary PASS;pnpm test:unit 17712 passed、零新增失败(9 suite 失败与前轮完全相同,全部在与 origin/main 零 diff 的文件里:cindy-media better-sqlite3 collect ×8、skillhub Windows 路径分隔符 ×2、mobile 源码扫描 ×1,环境/存量问题)。 第 4 项(隔离自愈点火)在 index.ts 私有对账函数内,无直测(成因与此前登记一致: index.ts 拉起整张 main 单例图);其依赖的公开契约(approveTrustedBundledInstall 的 隔离清除与启停合并、spawnIfResident 的启用自查)已有既有用例覆盖。 Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
This reverts commit eb6556d(那条 commit 回滚了 #1080 的 merge 890d4b3)。 #1080 的安全方向是对的(#636:就地改写安装目录让权限 diff 以被改过的现场为基线, 未确认的 slot 因此拿到运行授权),被回滚只因落地方式违反了随后新立的规则 §5「存量 插件兼容:升级必须无感」—— 把「无 receipt = 不构成运行授权」一次性作用到全部存量安装, 市场/本地包没有 backfill,升级后全部非随包插件同时失效。 本 commit 只做机械 reland:把 #1080 的全部实现原样带回(revert 那个 revert),与回滚后 落地的 4 个 PR(SuperGrok/ChatGPT 图片、确认卡刷新、refMode 视频)自动三方合并、零文本 冲突,typecheck + src/main/cindy-brain 84 files / 1187 tests PASS。 规则文档三方合并结果符合预期且刻意保留: - §3 恢复 #1080 的 receipt / ghostContentTree / 强化 skill 槽描述; - §5「存量插件兼容」红线(b7ebb8f 独立提交所立、非 #1080 内容)完整保留 —— 它正是本次 reland 的验收标准; - §7 第一条「存量安装没有 receipt backfill【未修复|违反第 5 节红线】」保留 —— 这条 缺口由紧随其后的迁移 commit 消除,本 commit 不动它(reland 只还原代码、不含迁移)。 **单独 reland 尚不可合并**:此刻它与被回滚时的行为完全一致,仍会让存量插件失效。 必须与后续「一次性 legacy backfill 迁移」commit 一起才满足 §5。分成两个 commit 是为了 让 reviewer 能把"代码还原"与"新增迁移"分开看。 Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
#1080 把插件运行授权从可变安装目录搬到 Host receipt,升级前装的插件没有 receipt —— 上一版把它们一律落到 legacy-unapproved、逐个要求重新确认,导致回滚(违反随后新立的 docs §5「存量插件兼容:升级必须无感」红线)。本 commit 补上 §5 要求的一次性 backfill, 让 reland 后的 #1080 满足红线、可以合并。 ## 迁移机制 `GhostManager.migrateLegacyApprovalsOnce()`,在每轮 reconcile 前跑(首次之后凭 `.legacy-migration.json` ledger 瞬时 no-op)。对每个没有 receipt 的安装目录,从旧的三份 事实源 backfill 出等价 receipt: - manifest ← ghost.json(过 validateGhostManifest); - enabled ← .disabled 镜像(旧模型的启停事实); - trust ← .cindy-trust.json(新增迁移专用读取器 readLegacyInstallTrust;缺失/损坏降级 为 unverified —— trust 是展示信号,能力由 slot 授予,不比旧模型少展示); - skillContentSha256 现算 + 经 receiptStore.write({skillSourceDir}) 建快照(复用既有安全 机制:逐字节校验、原子 rename、快照发布后复核); - packageSha256 省略(audit-only、运行期不消费),迁移更快、不因安装目录异常条目误伤。 随包种子 id 跳过 —— 走 provisioning 的 approveTrustedBundledInstall(有权威字节可比)。 ## 三条安全不变量(见 GhostManager 头注释与 §5/§7 文档) 1. **全局一次性**:状态根有迁移 ledger 即视为已迁过,此后缺 receipt 一律 fail closed、 不再迁。否则删掉 receipt 就能骗一次"从可变安装目录重建授权"。这道门是**充分**守卫: §7 已登记"状态根无写保护、能写它的进程可伪造结构合法的 receipt",能删 ledger 的进程 本就能直接写伪造 receipt,迁移路径严格弱于它,不构成新攻击面。 2. **不扩权、不等于新确认**:receipt 权限集 = 当前 ghost.json 声明,等价于旧模型无条件 授权的那一组;此后任何 manifest/权限变化照旧走完整确认(update 流程不变)。迁移来源 记进 ledger 的 migratedIds,便于事后分辨"用户确认过"与"迁移来的"。 3. **只写状态根、绝不动安装目录**:三份旧文件原样保留 —— 回滚到旧客户端时它照旧从安装 目录判定启停,不错位(§5 兜底第 4 条 回滚余地)。 ## 分级 fail(§5:核心事实读不出才 fail closed,展示元数据缺失则降级) manifest 不合法 / 技能目录含链接 / 声明的 locale 装入后损坏 → 该插件保持 fail closed、 走恢复 UI(装入流程本就校验 locale,读到坏 locale 只可能是装入后损坏 = §5 的"自相矛盾"); trust 缺失 → unverified;icon 读失败 → 无图标。迁移整体失败(状态根不可写)不挡播种, 下一轮启动重试。 ## 本地体验(验收标准第 2 条) `pnpm demo:legacy-migration`(scripts/dev-legacy-migration-demo.mts):用真实 GhostManager 在临时目录造旧布局、跑迁移、打印迁移前后 list()、台账、一次性门与回滚余地验证;不触碰 真实数据。脚本末尾附「在真实 app 中体验」的手动步骤(删 receipt + ledger → 重启观察无感)。 ## 回归覆盖(§5 硬性:从旧状态升级的自动化用例;关键两条已实测还原判据即精确变红) GhostManager.test.ts 新增「存量插件一次性迁移」10 条:市场/本地无感迁移(升级后仍启用)、 停用态保留、trust 保留 + 缺失降级、skill 槽迁移后快照校验通过、**全局一次性门挡住二次 迁移**(还原 ledger 门必红)、迁移不改安装目录三文件、坏 manifest fail closed、坏 locale fail closed、随包 id 跳过、已有 receipt 不覆盖。 ## 验证 pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 84 files / 1197 tests PASS。docs §7 首条从「未修复」更新为「已修复|迁移」。 Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
这次改了什么
摘要
插件的「用户批准过什么」这件事,此前唯一的事实来源就是安装目录里的
ghost.json/.cindy-trust.json/.disabled—— 而这个目录是可变的、产品里还有多条路径把用户和Agent 引到那里去改(详情页可显示/复制/打开安装目录,Forge 不禁止拿已安装目录当源码
目录)。结果是:就地改写安装目录里的清单,可以让更新确认框的「旧版本」和权限 diff
以被改过的现场为基线显示「无新增」,未经确认的能力声明因此拿到运行授权。
这个 PR 把授权事实搬到安装目录之外:一次明确的安装/更新确认会写出一份 Host 持有的
批准 receipt(batch 的 manifest + trust + 启停态 + 随机 revision),放在
owner-scoped 的
ghost-install-state状态根里;GhostManager.list()的 manifest /trust / enabled 一律只从 receipt 取,安装目录里那三样退化为旧版本兼容镜像。没有
receipt 或 receipt 损坏 = 不构成运行授权(列为停用、不许启用、不参与技能落链),
只能走一次完整重新确认恢复。
配套补齐三处对称性:更新事务把批准态投影成 token 由 Renderer 回传、Main 现读比对
(不一致就
state-changed拒绝,与既有的expectedPackageSha256形成对称);Forge的 scaffold / pack 按 realpath 拒绝 Host 受管根及其后代(含大小写与软链/junction
别名);越出沙箱的 skill 槽在确认时逐字节拷成带 revision 的批准快照,共享技能根的
链接指快照而不是可被改写的安装目录。
审查后追加(第 5 轮,commit bce8fab / 7545bc1 / dc2f4cb / 8220a50)
审查中暴露出一个模式:「怎么读插件内容目录」这条判据在六处各写一遍(技能指纹、快照
拷贝、安装目录漂移指纹、随包种子指纹、种子复制、Forge 打包收集),另有五处各自
path.join后再判一次类型,分别用 Dirent 类型位 /lstat/stat/ realpath 钳制实现。于是每一轮审查都能在其中一处找到没覆盖的角落——补一处、下一轮换另一处。所以这
一轮先消掉分叉本身(新增
ghostContentTree.ts作为唯一判据,差异只允许以显式策略参数表达),再在统一后的判据上修四条具体反馈。
其中一条不是 reviewer 提的,是收敛过程中自查发现,且比任何一条 review 反馈更硬:
原内容摘要编码
path + NUL + bytes + NUL有歧义——文件内容可以合法包含 NUL,于是{ a: "x\0b\0y" }与{ a: "x", b: "y" }在进入 SHA-256 前形成完全相同的字节流,即批准指纹本身可被构造碰撞。现改为
cindy-ghost-content-v2+ UTF-8 路径长度前缀 + 每文件定长摘要,并同步 bump receipt schemaVersion(见「是否存在 breaking change」第 5 项)。
另外三条对应 review thread:随包/安装目录指纹忽略非普通条目(塞一条链接即被判「与种子
逐字节相同」并自动批准)、既有技能快照被无条件信任(缺失快照走 hash-checked 重建、既有
快照直接早退,两条路判据强度是反的)、Forge 受管根只做单向包含判定(源目录是受管根的
祖先时,递归打包会把已安装插件字节、批准 receipt 与技能快照打进
.cindy)。变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
关联 Issue / 需求:插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636(不使用
Closes,理由见「明确不包含」第 1 条 —— 第 7 项加固要求只落了一半,是否结案请维护者判断)
本 PR 包含(对应 插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636 第 4 节九项加固要求):
main/cindy-brain/ghostInstallReceipt.ts;状态根ownerScopedUserDataPath('ghost-install-state');构造期断言内容根与状态根互不包含shared/ghost.ts新增diffInstalledGhostPermissionItems();installFlow.tsx、GhostPluginPage.tsx、plugin-market/service.ts全部改吃它,无批准基线时把候选包全部权限当新增项列出GhostManager.list()只从 receipt 取 manifest / trust / enabled;非 approved 一律enabled: falsepackGhostDir按 realpath 拒绝安装目录及其子目录,覆盖软链、junction、Windows 大小写绕过forge.ts的packGhostDir/scaffoldGhostDir共用forbiddenRootDirs,来源是GhostManager.managedRootDirs();新增错误码SOURCE_IS_INSTALLED_PLUGIN;本轮改为双向判定(源目录既不能落在受管根内,也不能是它的祖先),且递归收集不跟随链接进受管根ghosts:inspect/ghosts:update贯穿并校验预期安装 revision,不一致即失效ghostInstallApprovalToken()/isGhostInstallApprovalToken();ghosts:update与市场安装都必填expectedInstalledApproval,Main 现读比对,不一致返回PRECONDITION_FAILED.cindy-trust.json)纳入宿主侧存储或签名校验readInstalledTrust()删除,安装目录里的文件降级为兼容镜像GhostManager.test.ts/forge.test.ts/installFlow.test.tsx/service.test.ts等新增用例:软链替换 locale 快照、大小写与 junction 别名、legacy 与损坏 receipt fail closed、revision 竞态、卸载清理、快照缺失时仍可停用、内容漂移拒绝重建、locale 键不一致失效forge.ts内嵌手册第 7 节、packages/cindy-tools/src/types.ts错误码、docs/dev-rules/plugin-security-and-authoring.md另外补了两处实现上绕不开的配套:
GhostManager的runExclusiveMutation()把内容目录与 receipt 的改写串成一条事务车道(否则 receipt 与目录可能各写一半);随包内置
插件由 provisioning 在逐字节对账后自动补批准(见下方档 1)。
明确不包含:
0. 批准状态根自身的写保护 —— 只剩消费侧窗口。
<userData>/ghost-install-state/对同权限本机进程可写。审查过程中把能在写入侧关掉的都关掉了(commit 7545bc1):
技能快照的字节指纹在每次写批准事实时都重新核对——接受既有快照前、复制到临时目录后、
rename就位后各一次(skillSnapshotMatchesReceipt是唯一判据);技能对账在建立或保留共享链接前也重算整棵快照。因此「复制完到 rename 之间被改写」与「快照事后
被就地改写」都会在下一次写批准事实或下一轮对账时暴露:对不上就删掉重建,重建仍要过
安装目录的字节校验,安装字节也漂移时一律拒绝并退回完整重新确认。
仍未关闭的是消费侧:对账那次核对之后、主 Agent 顺着共享技能链接实际读取之前,
快照仍可被改写——Agent 的读取路径不在宿主控制内,宿主不做逐次校验。receipt 同理:
有严格结构与字段校验(改坏即判
invalid、fail closed),但没有签名或 MAC,能写状态根的进程可以伪造一份结构合法的批准。彻底关闭需要签名/MAC 或 OS 级写保护,未做。
已按「写入侧已关 / 消费侧未关」两条分别登记进
plugin-security-and-authoring.md第 6 节;不得把「写入侧已核对」说成「消费时读到的一定是被批准的字节」。
逻辑页代码仍从
cindy-brain/<id>/现读,packageSha256只是批准时点的来源指纹、运行期没有任何地方校验它。能写这个目录的本机进程仍可替换代码,只是被限制在
此前已批准的权限集内运行、且无法再借改写
ghost.json扩权。这个残留缺口已写进plugin-security-and-authoring.md第 6 节,避免后人误以为已有内容完整性保证。手册要求 Agent 自己迁出"。
产品/安全取舍,本 PR 刻意不做。
用户可见变化:升级到本版本后,存量安装分三档,差别很大,请按档看:
档 1 · 随包内置插件(
cindy-*官方插件)—— 用户无感,无需任何操作。启动对账时 provisioning 已经把安装目录与随包种子目录逐字节比对过(
hashDirContent相等,或刚刚整目录换入),此时宿主自动补写批准 receipt,原有启停态保持不变。
这里的「自动」有权威字节可比:种子来自应用安装包本身。
档 2 · 从插件市场安装的插件 —— 需要用户手动点一次「重新确认权限」,在此之前不工作。
这类安装没有历史批准记录,升级后会被列为停用,卡片上出现「需要重新确认权限」徽标和
「重新确认权限」按钮;点它会走市场同一 release 的重装确认流程,权限逐条按新增项列出,
确认后恢复。已录入的凭证不会丢(保险库按插件 id 存,重新确认走的是 update 而不是
卸载重装),插件的面板位置与配置也保留。
但请注意这是本 PR 刻意做的取舍,值得单独拍板: 技术上完全可以像档 1 那样自动补
批准 —— 市场侧有 release 记录,可以比对安装字节与 release 哈希。本 PR 没有这么做,
理由是:市场哈希只能证明「安装内容等于某个 release 的字节」,不能证明「用户看过并
同意过该 release 的权限集」。用它自动生成一份批准,就是再一次把「现场内容可信」
当成「授权已获得」—— 这正是 插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636 的同类错误。存量安装的历史确认记录不存在,宿主无从
证明用户当初同意的是哪一组权限。
代价也要摆明:所有市场安装的插件在用户点确认之前都不工作,包括已配好凭证的插件,
以及定时任务依赖的插件工具会在此期间失败(停用插件不注册工具、不参与技能落链)。
如果维护者认为这个一次性摩擦不可接受,一个可行折中是:仅当安装字节与市场 release
哈希完全一致、且该 release 的权限集与当前 manifest 完全一致时自动补 receipt,并在
UI 明确标注「由市场记录自动恢复,未经你本人确认」。这需要产品与安全一起定,不该由
本 PR 隐含决定,所以这里明确留给维护者拍板。
档 3 · 本地手动装入的
.cindy插件 —— 必须用户重新选一次包确认,无法自动化。没有任何权威哈希可比对(包文件早已不在手上),只能让用户重新选包走完整确认。
另外:receipt 损坏(被外部改坏/截断)与档 2/3 走同一条恢复路径,只是文案不同
(
bodyInvalidvsbodyLegacy),一样 fail closed 不许启用。两处已知毛边,请维护者一并看:
invalid态并提示「请重新选择安装包并确认权限」——但随包插件根本没有.cindy可选。fail closed 的方向是对的,正确文案应是「重启应用即恢复」(下一轮启动对账会重新补批准)。
本 PR 未改:需要新增第三条 reapproval route + 四语文案,会引入新的 UI/术语门禁面,
而触发前提是状态根不可写这种坏环境。建议单独一个小 PR 处理。
(runtime / node broker / agent slot 三连);若后一轮对账把批准补回来,它仍保持沉睡,
因为
spawnIfResident在 reconcile 里只覆盖outcome.installed,不覆盖outcome.approved;下次启动的全量扫描会把它拉起来。本 PR 未改:让它同会话内自动重启,需要给「后一轮补回批准」这条路径也加点火,而那条路径缺少「用户此刻想让它跑」
这个信息,容易把本该沉睡的实例点亮——为消一个可解释的行为引入新风险不值当。
是否存在 breaking change:有。
非 API 变化)。
ghosts:update与pluginMarket.install(对已安装目标)新增必填的expectedInstalledApproval,缺失或格式不合法直接INVALID_PARAMS/PRECONDITION_FAILED。调用方只有 Renderer 自身,已同步。GhostManager.update()的opts由可选变必填(expectedInstalledApproval)。InstalledGhost新增必填字段approval,消费方已全部同步。GhostInstallReceiptschemaVersion 1 → 2,旧 receipt 一律失效。 内容摘要编码换成无歧义 framing(见「审查后追加」),旧 receipt 里的
skillContentSha256在新校验器下无意义,因此 schemaVersion 不匹配直接判
invalid、fail closed、需重新确认——刻意不留「字段/版本不认就跳过校验」的兼容口子,那本身就是绕过点。对已发布版本的用户无影响
(receipt 格式尚未随任何版本发布过);但在本分支上测过的环境里,已写出的 receipt 会
全部失效:随包插件(档 1)下一轮启动对账自动补回,市场/本地安装(档 2 / 档 3)需要
再点一次「重新确认权限」。
UI 变化
界面效果证据见评论 #issuecomment-5138786803:
pr636-light.png/pr636-dark.png(Light / Dark 各一张全量状态图)+
pr636-ui-preview.html(自包含单文件,两模式并排)。产出方式:真实 React 组件在 jsdom 渲染后导出的 DOM + 仓库 i18n 文案 +
exportThemeColors()从内置主题实时导出的 481 个 token + 仓库 tailwind 配置编译出的 CSS,非手绘示意图。
仍未做实机双模式目检(会话内无法重启宿主实例),按
DESIGN.md§10 如实声明。UI 改动集中在两处,均为复用既有同级组件形态、只换文案与触发条件:
GhostPluginPage.tsx):名称后新增「需要重新确认权限」chip徽标(与既有「有新版本」徽标同一形态:
rounded-full bg-[var(--surface-chip)] px-2 py-0.5 text-10 text-[var(--text-secondary)],逐字一致);右侧动作区把「使用/更新」按钮换成「重新确认权限」按钮(与既有卡片更新按钮的 class 串逐字一致:
h-8 rounded-lg border-[var(--border-default)] bg-transparent px-3 text-12);开关置灰(
disabled={toggleDisabled || needsReapproval})。GhostPluginDetailView.tsx):头部动作区把更新按钮换成「重新确认权限」按钮(与既有更新按钮 class 串逐字一致:
h-10 rounded-full border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 text-13);头部下方新增一块
role="status"说明卡(复用本文件既有的DETAIL_SURFACE_CLASS+rounded-xl px-4 py-3.5),标题 + 一句话说明为什么被停用、怎么恢复。DESIGN.md§10「Token Selection Rules for New UI」第 5 条:新增样式没有任何硬编码色值,全部走语义 token(
--surface-chip、--surface-elevated、--border-default、--text-primary/--text-secondary、--surface-hover-soft、--focus-ring),未引入
bg-[#xxx] dark:bg-[#xxx]形态。DESIGN.md§10「Light / Dark Dual-Mode Delivery Gate」:两种模式都由上述语义 token覆盖(实现层面完成)。按该节最后一条如实说明:本次没有做实机双模式目检,见
「未执行的验证」;不把「复用了 themed 样式」当作双模式已验证。
DESIGN.md§4「Cards & Containers」:说明卡用 12px 容器圆角(rounded-xl)、--surface-elevated底、无阴影。DESIGN.md§5「Border Radius Scale」:详情页按钮取 pill(rounded-full)、徽标取pill、说明卡取 12px 容器档。一处需要说明的偏离:列表卡片内的「重新确认权限」
按钮用的是 8px(
rounded-lg)而非 pill —— 这是照抄同一位置既有「更新 / 使用」按钮的现状(
GhostPluginPage.tsx同一 flex 容器内三个按钮 class 串完全一致),本 PR没有引入新的偏离,也没有顺手改既有按钮(超出本 PR 范围)。若需要统一为 pill,
建议单独开一个 UI 一致性 PR 一起改,以免混进安全修复的 diff。
DESIGN.md§11「Voice & Content」:文案用大白话说清「发生了什么 + 怎么办」,不出现 receipt / revision / approval 等实现词;zh-CN / en / ja / ko 四语同步新增,
pnpm check:i18n-glossary通过。怎么验证的
自动验证
关于用例性质的说明(避免把弱用例当成强证据):下列用例中,
rejects bytes swapped after the hash check but before the snapshot copy finishes、keeps an install unusable when a stale approval cannot be revoked、applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot以及各条 drift/legacy/revision 用例,都逐一还原成修复前的行为验证过精确变红,是真回归闸。
唯一例外是
refuses to follow a link planted inside the skill directory when rebuilding:它是行为钉住而非回归闸——当前 libuv 就把 junction 与目录软链报成 link,判据从 Dirent
类型位换成逐条
lstat前后结果相同;它的价值在于哪天 Node/libuv 改变 dirent 语义会立刻变红。新增回归用例(节选,全部通过):
keeps manifest, enabled state, and trust independent from mutable install filesfails legacy and corrupt receipts closed until a fully reviewed update replaces themrejects an update when the approved revision changed after reviewkeeps disabling possible when the approved skill snapshot is gone, and rebuilds it on enablerefuses to rebuild an enable-time snapshot from install bytes that drifted from the approved manifestinstalled locale symlinks cannot replace the Host-approved locale snapshotinstalled icon removal cannot replace the Host-approved icon snapshotrejects Host-managed roots, descendants, case aliases, and junction aliases(pack 与 scaffold 各一份)prunes skill snapshots left behind by superseded approval revisionstreats receipt cleanup failure after content removal as a completed uninstallrefuses to rebuild a snapshot when only the SKILL.md body driftedrefuses to rebuild a snapshot when a helper file was added to the skill directorystill heals a deleted snapshot when the installed skill bytes are untouchedinvalidates a receipt whose skill content digests no longer match the manifestrejects bytes swapped after the hash check but before the snapshot copy finishesapplies the SKILL.md size ceiling to the bytes that actually landed in the snapshotkeeps an install unusable when a stale approval cannot be revokedrevoking approval fails the install closed, and a later bundled approval heals itrefuses to follow a link planted inside the skill directory when rebuilding(行为钉住,见上)第 5 轮新增(对应「审查后追加」,除注明者外均已还原判据验证过精确变红):
rejects a link in an intermediate path segment instead of hashing bytes from outsidenever reads icon bytes from outside the plugin dir when a path segment is a linkinvalidates a schema v1 receipt instead of trusting its legacy content digestsuses unambiguous framing when file bytes contain NUL separatorsdoes not trust an already-present snapshot whose bytes were rewritten in placerefuses to keep a rewritten snapshot when the installed bytes drifted too完整摘要校验不通过时撤掉已有托管链接,不因目标未变而 keptflags a planted link instead of folding it into the content hashkeeps type out of the hash so a sentinel-valued regular file stays distinguishablere-seeds when a seed file was replaced by a link, even if its bytes could spoof a hash sentinelre-seeds when a dot-named link was planted, even though dot entries stay out of the hash种子含非普通条目时跳过,不交换目录也不申请批准rejects a source directory that contains a Host-managed rootdoes not follow a link inside the source dir into a Host-managed rootrejects a declared file that is a link instead of packing a package without it手工验证
不涉及 / 未执行:本次改动在无法重启宿主 dev 实例的会话内完成,没有做实机手工验证。
需要实机确认的点(建议 reviewer 或后续在能重启的环境补):
未执行的验证
pnpm test:unit在本机没有全绿,失败与本改动无关,已用「暂存本次改动、在未改动的
origin/main上跑同一组用例」的方式逐一确认为既存环境问题:src/main/cindy-media/__tests__/下 8 个 suite 在 collect 阶段报SyntaxError: Unexpected identifier 'Database'(better-sqlite3 原生模块在本机Windows 下的加载问题)。这 8 个在 clean baseline 上完全相同地复现。本改动不涉及
cindy-media。src/main/__tests__/clientEndpointsService.test.ts > 目录项换成了别的 inode → 不通过(Windows 的 ino/dev 语义差异)在较早一次运行中失败,同样在 clean baseline 上复现;在最终基线的复跑中它通过了,属于环境相关的
不稳定用例。仓库已有
d2ea93a2 test(desktop): 修 Windows 本地跑不过的环境性用例这类先例。
本改动没有新增任何失败,断言层 0 失败。最终以 CI 门禁为准。
双模式实机目检未做(Light / Dark 都只做到实现层面走语义 token)。原因同上:
会话内无法重启宿主实例。按
DESIGN.md§10 双模式交付门槛最后一条如实声明。UI 截图 / 录屏未提供,原因同上。
pnpm test:all未跑(本 PR 不含 DB migration / 协议 / 原生层改动,按风险分层未追加)。macOS 路径行为未实测(realpath 大小写与 junction 别名用例在 Windows 上跑过;
macOS 大小写不敏感卷的行为由同一 realpath 判定逻辑覆盖,但未在 macOS 实跑)。
本机
pnpm install需要绕过引擎门。 merge 进来的origin/main把 lockfile 里的undici 提到 8.7.0(
engines.node >= 22.19.0),本机 Node 22.14.0 会被.npmrc的engine-strict=true拦住。本轮用一次性pnpm install --config.engine-strict=false完成安装,未改动 lockfile 与
.npmrc(已核对 lockfile 与origin/main逐字节一致)。CI 的 Node 若 ≥22.19.0 则无影响;否则这是与本 PR 无关的独立问题。
风险
风险分类
影响与回滚
.cindy)的装入 / 更新 / 启停 / 卸载全链路,插件运行期能力授权判定,skill 槽落链目标,Forge 的 scaffold / pack 准入,插件页与插件市场 UI,
以及依赖插件工具的定时任务(存量安装重新确认前不可用)。不涉及数据库 schema、
不涉及 system prompt、不涉及 mobile 原生层与 runtime fingerprint。
大小写折叠与 directory junction、以及 macOS 大小写不敏感卷。Windows 侧有用例覆盖,
macOS 未实跑(见「未执行的验证」第 5 条)。
ownerScopedUserDataPath('ghost-install-state')下的<id>.json(0600)与skill-snapshots/<id>/<revision>/。均为宿主状态、不含凭证明文,写入走同目录临时文件 + rename 原子提交,旧 revision 快照在 receipt 提交后回收。
回滚后
GhostManager重新以安装目录为事实来源,遗留的ghost-install-state目录变成无人读取的孤立状态(可留可删)。启停态在整个过程中由安装目录里的
.disabled兼容镜像同步维护,所以回滚到旧客户端不会出现「批准态说停用、旧客户端却点亮」的
错位。降级运行旧版本客户端同理可用。
提交前检查
git commit -s)docs/dev-rules/plugin-security-and-authoring.md、forge.ts内嵌编写手册)给 reviewer 的两点提示
docs/dev-rules/plugin-security-and-authoring.md,会触发 review-pr 流程自动创建「规则文档改动讨论」issue 并暂停审查,等规则放行人确认(同 feat(plugins): unified plugin lifecycle readiness across discovery, enable, UI, scheduler, and credentials #668 / feat(desktop): 插件召唤卡改为气泡内标注行,法阵动画补终态编舞 #917 的
情况)。规则改动与实现是一体的:把「授权事实由 Host receipt 持有」「Forge 源码区与
受管根互斥」「启用需批准、停用必成功」写成审查判据,同时在第 6 节如实登记了残留的
内容完整性缺口。
feat(plugins): unified plugin lifecycle readiness)有 15 个文件重叠(
shared/ghost.ts、cindy-brain/index.ts、preload.ts、installErrorKey.ts、GhostPluginPage.tsx、GhostPluginDetailView.tsx、vite-env.d.ts、cindy-tools/src/types.ts、plugin-security-and-authoring.md、四个 locale 等)。两者语义不重复(feat(plugins): unified plugin lifecycle readiness across discovery, enable, UI, scheduler, and credentials #668 不涉及批准状态),但都在扩
InstalledGhost与插件 IPC 面,后合的一方需要 rebase。