From 765198d2701d8fe190efe522fccf78214614ff2a Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 00:01:16 -0400 Subject: [PATCH 1/9] wip: #706 V3 ledger cutover (inherited) --- packages/ui-mac/src/main/alpha-installs.ts | 27 + packages/ui-mac/src/main/ext-config.test.ts | 12 +- packages/ui-mac/src/main/ext-config.ts | 15 +- .../ui-mac/src/main/ext-fs-installer.test.ts | 36 +- packages/ui-mac/src/main/ext-fs-installer.ts | 15 +- .../ui-mac/src/main/ext-install-planner.ts | 24 +- packages/ui-mac/src/main/ext-ipc.ts | 11 +- .../src/main/ext-package-ledger-commit.ts | 46 ++ .../src/main/ext-package-ledger-v3.test.ts | 310 +++++++++++ .../ui-mac/src/main/ext-package-ledger-v3.ts | 489 ++++++++++++++++++ .../ui-mac/src/main/ext-receipt-v2.test.ts | 4 +- packages/ui-mac/src/main/ext-receipt-v2.ts | 317 +++++++++++- packages/ui-mac/src/main/ext-transaction.ts | 20 +- .../src/main/package-admission.parity.test.ts | 35 ++ packages/ui-mac/src/main/package-admission.ts | 66 ++- 15 files changed, 1381 insertions(+), 46 deletions(-) create mode 100644 packages/ui-mac/src/main/ext-package-ledger-commit.ts create mode 100644 packages/ui-mac/src/main/ext-package-ledger-v3.test.ts create mode 100644 packages/ui-mac/src/main/ext-package-ledger-v3.ts diff --git a/packages/ui-mac/src/main/alpha-installs.ts b/packages/ui-mac/src/main/alpha-installs.ts index 659efbb9ee64..5b4c99f8333f 100644 --- a/packages/ui-mac/src/main/alpha-installs.ts +++ b/packages/ui-mac/src/main/alpha-installs.ts @@ -126,7 +126,34 @@ function readCarriedRecords(root: string): unknown[] { } } +/** + * REQ-128 `#706`:V3 信封在场时,这个 v1 写器**必须拒写**。 + * + * 它是账本的第二个物理写器,重写时只认得 `receipts` 与(透传的)`records` 两个键 —— + * V3 新增的 `packageGraphs` / `claims` 是新的顶层键,写一次就静默蒸发,而 claim 恰恰是 + * 「这个 child 还有没有别人在用」的唯一凭据。生产调用方已在 `#706` 里全部删除;这道闸 + * 挡的是「以后有人再把它接回来」和「旧构建拿到 V3 账本」两种情况(基线 §2.9:downgrade + * 必须 fail-closed,不能悄悄抹掉 claims)。 + */ +function refuseIfV3(root: string): string | null { + let raw: unknown + try { + raw = JSON.parse(fs.readFileSync(ledgerPath(root), "utf8")) + } catch { + return null // 缺席 / 损坏:v1 写路径的既有语义(quarantine)照旧 + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const envelope = raw as { v?: unknown; packageGraphs?: unknown; claims?: unknown } + const hasV3Sections = + (Array.isArray(envelope.packageGraphs) && envelope.packageGraphs.length > 0) || (Array.isArray(envelope.claims) && envelope.claims.length > 0) + if (envelope.v === 3 || hasV3Sections) + return `install ledger is envelope v3 (package graphs/claims present) — the v1 writer would silently drop them; use the V3 repository (ext-receipt-v2): ${ledgerPath(root)}` + return null +} + function writeLedger(root: string, receipts: InstallReceipt[]): LedgerWriteResult { + const v3 = refuseIfV3(root) + if (v3) return { ok: false, reason: v3 } try { fs.mkdirSync(root, { recursive: true }) const file = ledgerPath(root) diff --git a/packages/ui-mac/src/main/ext-config.test.ts b/packages/ui-mac/src/main/ext-config.test.ts index 02739d4a9efd..7d095c38f76f 100644 --- a/packages/ui-mac/src/main/ext-config.test.ts +++ b/packages/ui-mac/src/main/ext-config.test.ts @@ -382,10 +382,13 @@ describe("receipts on persist/remove (T6)", () => { test("#354:persistMcp 不再 eager 落 v1(账本所有权归 planner v2 upsert);removeMcp 仍清 legacy receipt", () => { expect(persistMcp("markitdown", { type: "local", command: ["uvx", "markitdown-mcp"] }, { catalogId: "mcp:markitdown", version: "1" }).ok).toBe(true) expect(readLedger(alphaTmp).receipts.find((x) => x.type === "mcp" && x.name === "markitdown")).toBeUndefined() - // legacy receipt(历史安装)仍由 removeMcp 清理 —— 卸载语义不变。 + // REQ-128 `#706`:配置写器**不再**碰账本。原先内层 removeReceipt 走 v1 物理写器,会把 + // 账本重写成 v:2 并抹掉 V3 的 packageGraphs/claims;去账只归外层单点提交。 addReceipt(alphaTmp, { id: "mcp:markitdown", name: "markitdown", type: "mcp", scope: "global", installedAt: new Date().toISOString(), origin: "catalog", configKey: "mcp.markitdown" }) + const before = fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8") expect(removeMcp("markitdown").ok).toBe(true) - expect(readLedger(alphaTmp).receipts.find((x) => x.name === "markitdown")).toBeUndefined() + expect(fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8")).toBe(before) // 账本字节零改动 + expect(readLedger(alphaTmp).receipts.find((x) => x.name === "markitdown")).toBeDefined() }) test("#354:persistPlugin 不再 eager 落 v1;removePlugin 撤 config[] 并清 legacy receipt", () => { @@ -393,9 +396,12 @@ describe("receipts on persist/remove (T6)", () => { expect(readConfig().plugin).toContain("opencode-notify@0.3.1") expect(readLedger(alphaTmp).receipts.some((x) => x.type === "plugin")).toBe(false) addReceipt(alphaTmp, { id: "plugin:opencode-notify", name: "opencode-notify", type: "plugin", scope: "global", installedAt: new Date().toISOString(), origin: "catalog", configKey: "plugin:opencode-notify@0.3.1" }) + const before = fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8") expect(removePlugin("opencode-notify@0.3.1").ok).toBe(true) expect(readConfig().plugin ?? []).not.toContain("opencode-notify@0.3.1") - expect(readLedger(alphaTmp).receipts.some((x) => x.type === "plugin")).toBe(false) + // REQ-128 `#706`:同上 —— 撤 config[] 与去账彻底分家,账本字节零改动。 + expect(fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8")).toBe(before) + expect(readLedger(alphaTmp).receipts.some((x) => x.type === "plugin")).toBe(true) }) test("removePlugin on an absent package is a no-op success", () => { diff --git a/packages/ui-mac/src/main/ext-config.ts b/packages/ui-mac/src/main/ext-config.ts index dc71444c4e19..28b53ae0904b 100644 --- a/packages/ui-mac/src/main/ext-config.ts +++ b/packages/ui-mac/src/main/ext-config.ts @@ -26,7 +26,7 @@ import { resolveMcpRefPath, } from "./alpha-mcp-secrets" import type { TxPreparedResourceV1 } from "./ext-transaction" -import { alphaGlobalRoot, removeReceipt } from "./alpha-installs" +import { alphaGlobalRoot } from "./alpha-installs" import { findRecordV2 } from "./ext-receipt-v2" import { alphaJsoncPath } from "./engine-config-truth" import { commandHeadBase } from "./platform" @@ -256,10 +256,6 @@ function applyBuiltinPolicyEditsUnlocked(edits: BuiltinPolicyEdit[]): BuiltinPol } } -function receiptsActive(): boolean { - return process.env.ALPHA_LEGACY_INSTALL_ROOT !== "1" -} - /** 纯校验(零写盘;REQ-102 #359 裁决 B:seed MCP 走 config action 时在 plan 生成前复用本门 —— * ext-config-tx 只保证 JSONC/顶层键,命令头/inline-eval/URL/危险 env 的安全门在此)。 */ export function validateServer(server: Record): ConfigResult { @@ -821,7 +817,8 @@ function removeMcpUnlocked(name: string): ConfigResult { /* unreadable legacy config → nothing to remove there */ } } - if (receiptsActive()) removeReceipt(alphaGlobalRoot(), "mcp", name) + // REQ-128 `#706`:账本副作用已从配置写器里删掉(v1 物理写器会抹掉 V3 的 packageGraphs/claims, + // 且发生在实物变更之后、返回值被忽略)。去账只归外层单点提交。 return { ok: true } } @@ -1175,7 +1172,8 @@ function removePluginUnlocked(pkg: string): ConfigResult { const r = dropFrom(legacy) if (!r.ok) return r } - if (receiptsActive()) removeReceipt(alphaGlobalRoot(), "plugin", base.replace(/^@/, "").replace("/", "__")) + // REQ-128 `#706`:账本副作用已从配置写器里删掉(v1 物理写器会抹掉 V3 的 packageGraphs/claims, + // 且发生在实物变更之后、返回值被忽略)。去账只归外层单点提交。 return { ok: true } } @@ -1278,7 +1276,8 @@ function removePluginPathUnlocked(name: string, absJsPath: string): ConfigResult return { ok: false, reason: `config unreadable (fail closed): ${file}: ${error instanceof Error ? error.message : String(error)}` } } } - if (receiptsActive()) removeReceipt(alphaGlobalRoot(), "plugin", name) + // REQ-128 `#706`:账本副作用已从配置写器里删掉(v1 物理写器会抹掉 V3 的 packageGraphs/claims, + // 且发生在实物变更之后、返回值被忽略)。去账只归外层单点提交。 return { ok: true } } diff --git a/packages/ui-mac/src/main/ext-fs-installer.test.ts b/packages/ui-mac/src/main/ext-fs-installer.test.ts index 29cb69099302..21a560d04593 100644 --- a/packages/ui-mac/src/main/ext-fs-installer.test.ts +++ b/packages/ui-mac/src/main/ext-fs-installer.test.ts @@ -21,7 +21,7 @@ mock.module("electron", () => ({ })) const { agentInstallPresent, collectBuiltinAgentPayload, collectVendoredPluginPayload, installBuiltinSkill, removeFsInstall, resourcesRoot, stageVendoredPluginVersioned, writeAgent, writeSkill } = await import("./ext-fs-installer") -const { readLedger } = await import("./alpha-installs") +const { addReceipt, readLedger } = await import("./alpha-installs") let base = "" let alphaDir = "" @@ -163,23 +163,47 @@ describe("installBuiltinSkill — name + asset-key guards", () => { }) }) -describe("removeFsInstall — deletes truth, unbridges, drops receipt (T6)", () => { - test("uninstalling a skill removes truth dir, .opencode item, and receipt", () => { +// REQ-128 `#706`:`removeFsInstall` **不再碰账本**。它原先在删完实物之后调 v1 `removeReceipt` +// 且忽略返回值 —— 那条路会把账本重写成 v:2(V3 的 packageGraphs/claims 静默蒸发),而且失败 +// 不可见。去账现在只归外层单点提交,claim-aware 判决在删实物之前就做完。 +describe("removeFsInstall — deletes truth and unbridges; the ledger is NOT its business (T6 / #706)", () => { + test("uninstalling a skill removes truth dir and .opencode item, and leaves the ledger untouched", () => { writeSkill("gone-skill", "d", "b") + addReceipt(alphaDir, { + id: "skill:gone-skill", + name: "gone-skill", + type: "skill", + scope: "global", + installedAt: new Date().toISOString(), + origin: "catalog", + }) + const before = fs.readFileSync(path.join(alphaDir, "installs.json"), "utf8") expect(fs.existsSync(path.join(alphaDir, "skills", "gone-skill", "SKILL.md"))).toBe(true) const r = removeFsInstall("skill", "gone-skill") expect(r.ok).toBe(true) expect(fs.existsSync(path.join(alphaDir, "skills", "gone-skill"))).toBe(false) expect(fs.existsSync(path.join(opencodeDir, "skills", "gone-skill"))).toBe(false) - expect(readLedger(alphaDir).receipts.some((x) => x.name === "gone-skill")).toBe(false) + // 账本字节零改动 —— 把内层副作用接回来会让这一行立刻变红。 + expect(fs.readFileSync(path.join(alphaDir, "installs.json"), "utf8")).toBe(before) + expect(readLedger(alphaDir).receipts.some((x) => x.name === "gone-skill")).toBe(true) }) - test("uninstalling an agent removes the md and receipt", () => { + test("uninstalling an agent removes the md and leaves the ledger untouched", () => { writeAgent("gone-agent", "---\ndescription: d\n---\nsys") + addReceipt(alphaDir, { + id: "agent:gone-agent", + name: "gone-agent", + type: "agent", + scope: "global", + installedAt: new Date().toISOString(), + origin: "catalog", + }) + const before = fs.readFileSync(path.join(alphaDir, "installs.json"), "utf8") const r = removeFsInstall("agent", "gone-agent") expect(r.ok).toBe(true) expect(fs.existsSync(path.join(alphaDir, "agents", "gone-agent.md"))).toBe(false) - expect(readLedger(alphaDir).receipts.some((x) => x.name === "gone-agent")).toBe(false) + expect(fs.readFileSync(path.join(alphaDir, "installs.json"), "utf8")).toBe(before) + expect(readLedger(alphaDir).receipts.some((x) => x.name === "gone-agent")).toBe(true) }) test("uninstalling a missing item is idempotent success", () => { diff --git a/packages/ui-mac/src/main/ext-fs-installer.ts b/packages/ui-mac/src/main/ext-fs-installer.ts index 43510bf27508..a5eec4e219a3 100644 --- a/packages/ui-mac/src/main/ext-fs-installer.ts +++ b/packages/ui-mac/src/main/ext-fs-installer.ts @@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url" import { opencodeHomeDir, unbridgeItem } from "./alpha-bridge" import { agentMdToEntry } from "./agent-md-entry" import { persistAgentEntry, readAgentEntry, readAgentEntryStrict, removeAgentEntry } from "./ext-config" -import { alphaGlobalRoot, removeReceipt } from "./alpha-installs" +import { alphaGlobalRoot } from "./alpha-installs" import { tryGetAlphaEnvironment } from "./alpha-environment" import { projectScopeIdentity, type ScopeIdentity } from "./ext-receipt-v2" import { checkUncuratedConflict, recordUncuratedInstall, type UncuratedOrigin } from "./ext-uncurated-record" @@ -392,9 +392,10 @@ function resolveRootsReadonly(target: InstallTarget | undefined): Roots | { erro } /** - * Uninstall a skill/agent: remove its truth dir/file under .alpha, unbridge its .opencode link, and - * drop the receipt. Legacy installs (ALPHA_LEGACY_INSTALL_ROOT era, no bridge/receipt) are removed - * from the old XDG root by name. Missing target = already-gone success (idempotent). + * Uninstall a skill/agent: remove its truth dir/file under .alpha and unbridge its .opencode link. + * REQ-128 `#706`:**不动账本** —— 去账只归外层单点提交。Legacy installs + * (ALPHA_LEGACY_INSTALL_ROOT era, no bridge/receipt) are removed from the old XDG root by name. + * Missing target = already-gone success (idempotent). */ export function removeFsInstall(type: "skill" | "agent", name: string, target?: InstallTarget): FsResult { if (!isExtensionName(name)) return { ok: false, reason: "invalid name" } @@ -431,7 +432,11 @@ export function removeFsInstall(type: "skill" | "agent", name: string, target?: } catch (error) { return { ok: false, reason: error instanceof Error ? error.message : "failed to remove" } } - removeReceipt(roots.alphaDir, type, name) + // REQ-128 `#706`:这里**不再**碰账本。原先内层 `removeReceipt` 有两个真问题: + // ① 它走的是 v1 物理写器,会把账本重写成 v:2 —— V3 的 packageGraphs/claims 静默蒸发; + // ② 它在删完实物之后跑、返回值被忽略 —— 账本拒写时用户看到「卸载失败」而东西已经没了。 + // 账本只由外层单点提交(uninstallByKey / 恢复期的 commitUninstall),claim-aware 判决则在 + // 删任何实物之前就做完(planDirectUninstall)。 return { ok: true, files: removed } } diff --git a/packages/ui-mac/src/main/ext-install-planner.ts b/packages/ui-mac/src/main/ext-install-planner.ts index be10e742fb2c..168d6f8d2a32 100644 --- a/packages/ui-mac/src/main/ext-install-planner.ts +++ b/packages/ui-mac/src/main/ext-install-planner.ts @@ -190,7 +190,9 @@ import { computeGrantDigest, findRecordV2, lookupForUninstall, + planDirectUninstall, projectScopeIdentity, + releaseStandaloneClaim, removeRecordV2, setDesiredStateV2, probeLedgerForWrite, @@ -276,7 +278,11 @@ export type CatalogInstallOutcome = | { ok: false; stage: "authorize"; reason: string; authorization: CapabilityDiff[] } | { ok: false; reason: string; stage?: Exclude } -export type UninstallOutcome = { ok: true; files?: string[]; warning?: string } | { ok: false; reason: string } +/** REQ-128 `#706`:`retainedForOwners` = 实物没删,因为它还属于这些 owner(仍在册的 Bundle); + * 用户自己那份 standalone claim 已释放。缺省 = 走了正常的删实物 + 去账。 */ +export type UninstallOutcome = + | { ok: true; files?: string[]; warning?: string; retainedForOwners?: string[] } + | { ok: false; reason: string } const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v) const RECEIPT_TYPES = new Set(["mcp", "skill", "agent", "command", "plugin", "bundle", "cloud"]) @@ -3826,6 +3832,22 @@ export async function uninstallByKey(rawIntent: unknown, deps: PlannerDeps): Pro } const configKey = record?.configKey ?? v1?.configKey + // REQ-128 `#706`(R2 Blocker 的直接修法):claim-aware 判决必须在**删任何实物之前**做完。 + // 这条路径的形状是「先删实物、再去账」,而 V3 的 repository 会在仍有 Bundle owner 时拒写 —— + // 判决放在后面,用户就会看到「卸载失败」而东西真的已经没了。 + // 仍有 Bundle 在用 ⇒ 一件实物都不动,只把用户自己那份 standalone claim 释放掉。 + const claimPlan = planDirectUninstall(root, intent.type, intent.name) + if (!claimPlan.ok) return { ok: false, reason: claimPlan.reason } + if (claimPlan.decision === "release-claim-only") { + const released = releaseStandaloneClaim(root, intent.type, intent.name) + if (!released.ok) return { ok: false, reason: `${intent.type} uninstall: ${released.reason}` } + return { + ok: true, + retainedForOwners: released.remainingOwners, + warning: `${intent.type}:${intent.name} is still part of ${released.remainingOwners.join(", ")} — removed your standalone claim and kept the files`, + } + } + const tx = (deps.transaction ?? passthroughTx).begin({ op: "uninstall", kind: intent.type, name: intent.name, scope: intent.scope }) const rollback = (reason: string): void => (deps.transaction ?? passthroughTx).rollback(tx.txId, reason) diff --git a/packages/ui-mac/src/main/ext-ipc.ts b/packages/ui-mac/src/main/ext-ipc.ts index 6ad13d3f7875..474f8cc37866 100644 --- a/packages/ui-mac/src/main/ext-ipc.ts +++ b/packages/ui-mac/src/main/ext-ipc.ts @@ -57,7 +57,8 @@ import { grantSessionGrant, revokeSessionGrant, sessionGrantRegistry } from "./e import { adoptProjectLedger } from "./ext-project-adopt" import { buildGatedWriteChannels, buildJournalAdminChannels, GATED_WRITE_CHANNELS, JOURNAL_ADMIN_CHANNELS } from "./ext-write-channels" import { tryAcquireBundleLock } from "./ext-bundle-lock" -import { lookupForUninstall, migrateV1Ledger, parseUninstallLedgerKey, readLedgerV2, removeRecordV2, upsertRecordsV2 } from "./ext-receipt-v2" +import { lookupForUninstall, migrateV1Ledger, parseUninstallLedgerKey, readLedgerV2, removeRecordV2 } from "./ext-receipt-v2" +import { commitTransactionLedger } from "./ext-package-ledger-commit" import { packagedSeedBrowseView, readPackagedSeed } from "./ext-seed" import { recoverExtensionTransactions, recoverExtensionTransactionsInHeldLock, recoveryClean, type RecoverOptions } from "./ext-transaction" import { getLogger } from "./logging" @@ -451,10 +452,10 @@ export function registerExtIpcHandlers( // 按 key 路由到各自类型化探针(#358 agent / #359 plugin payload);两者对各自方案外的 key // 均 fail-closed —— 未知 file item 绝不静默放行。组合有第二份 = 恢复与安装的健康判据会漂移。 probe: extensionHealthProbeRouter(root), - commitReceipt: (recs) => { - const written = upsertRecordsV2(root, recoveryReceiptInputs(recs)) - if (!written.ok) throw new Error(`recovery receipt commit failed: ${written.reason}`) - }, + // REQ-128 `#706`:前滚与主提交共用 `commitTransactionLedger` —— journal 里带着 package + // mutation 的事务在恢复期也必须重建**同一份** V3 mutation,否则前滚会写出一本没有 + // graph/claims 的账本(而 child records 已 durable),owner 集合从此失据。 + commitReceipt: (recs) => commitTransactionLedger(root, recs), // #336 r3(r2 Major 1):receipt durable 证伪 —— 恢复进入任何回滚分支前读账本判定。 // valid + 同 txId = durable(**任一** item 在账即禁回滚,防半批分叉);absent/v1/异 txId = // 确证未落(允许回滚);corrupt/ledger-corrupt = 无法证伪 → 抛错(引擎 fail-closed 保留 diff --git a/packages/ui-mac/src/main/ext-package-ledger-commit.ts b/packages/ui-mac/src/main/ext-package-ledger-commit.ts new file mode 100644 index 000000000000..80329c4cdf6b --- /dev/null +++ b/packages/ui-mac/src/main/ext-package-ledger-commit.ts @@ -0,0 +1,46 @@ +// REQ-128 `#706`:事务落账的唯一入口。 +// +// 一次扩展事务提交时,账本要么走 V2 的批量 child upsert(单装、生成、恢复前滚),要么走 V3 的 +// **一个** `PackageLedgerMutationV1`(package 安装/更新/卸载)。判据只有一个:commit records +// 里有没有 root item 带来的 `packageMutation`。 +// +// 为什么两条路必须共用这一个函数:主提交(`package-admission`)与崩溃前滚(`ext-ipc` 的 +// recovery seam)如果各自拼一份 mutation,就有两份「同一个事务应该写成什么」的答案,exact replay +// 也就无从谈起 —— 前滚会写出与主提交不同的账本,而两边都自认为成功。 + +import { recoveryReceiptInputs } from "./ext-agent-install" +import { decodePackageMutationEnvelopeV1, type PackageLedgerMutationV1 } from "./ext-package-ledger-v3" +import { applyPackageMutation, upsertRecordsV2 } from "./ext-receipt-v2" +import type { TxCommitRecord } from "./ext-transaction" + +/** + * 提交一次事务的账本副作用。失败一律 **throw** —— 事务层据此把 journal 保持在非终态, + * 下次启动前滚重试;吞掉错误会把「账没写」写成 committed。 + */ +export function commitTransactionLedger(root: string, records: TxCommitRecord[]): void { + const carriers = records.filter((rec) => rec.packageMutation !== undefined) + if (carriers.length > 1) + throw new Error(`transaction carries ${carriers.length} package ledger mutations — only the root package item may carry one`) + if (carriers.length === 0) { + const inputs = recoveryReceiptInputs(records) + if (inputs.length === 0) return + const written = upsertRecordsV2(root, inputs) + if (!written.ok) throw new Error(`receipt commit failed: ${written.reason}`) + return + } + const carrier = carriers[0]! + const decoded = decodePackageMutationEnvelopeV1(carrier.packageMutation) + if (!decoded.ok) throw new Error(`package ledger mutation rejected (fail closed): ${decoded.errors.join("; ")}`) + // transactionId 与 child 集合都来自**事务自己**,不接受 journal 里自带的副本 —— + // 同一份 commit records 在主提交与前滚两条路上算出的 mutation 因此逐字相同。 + // 计划期还不知道事务会分到哪个 txId(`runExtensionTransaction` 自产),envelope 里那个是 + // 占位;真值在这里统一覆盖,包括写进 packageRecord 的那份。 + const mutation: PackageLedgerMutationV1 = { + ...decoded.value, + transactionId: carrier.txId, + ...(decoded.value.packageRecord ? { packageRecord: { ...decoded.value.packageRecord, transactionId: carrier.txId } } : {}), + childRecordMutations: recoveryReceiptInputs(records).map((input) => ({ op: "upsert" as const, input })), + } + const written = applyPackageMutation(root, mutation) + if (!written.ok) throw new Error(`package ledger mutation commit failed: ${written.reason}`) +} diff --git a/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts b/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts new file mode 100644 index 000000000000..45967deb4e8a --- /dev/null +++ b/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts @@ -0,0 +1,310 @@ +// REQ-128 `#706` —— V3 账本类型层的强度闸。 +// +// 本文件盯三样东西: +// ① **文法不是我发明的**。packageId / 组件 id 的文法真源是宿主合同 schema(它和 decoder.ts +// 一起被钉进跨仓 artifact,本票不得改动那些字节)。所以本模块只能带一份副本 —— 副本就是 +// 「替别人写文法」,本仓最贵的返工形态。这里用**两条互相独立的轴**盯它:逐字比对 schema +// 的 pattern,以及把同一组 id 同时喂给真 decoder 与本正则比对判决。 +// ② **owner token 的解析对未知形状默认拒**。未知 owner 既不能被释放也不能被证明为空,放行 +// 等于把「还有别人要」和「谁都不要」混成一个格子。 +// ③ **不变量是落盘前的整体判据**,不是逐条字段校验:dangling claim / unknown child / +// 孤儿 owner —— 三者都是「owner 集合从此无法自证」的具体形态。 + +import { resolve } from "node:path" +import { describe, expect, test } from "bun:test" +import { decodePackageEnvelopeHeaderV1 } from "../shared/host-extension-package-contract/decoder" +import { HOST_EXTENSION_PACKAGE_CORPUS } from "../shared/host-extension-package-contract/generate-artifact" +import { RECORD_KINDS } from "./ext-receipt-v2" +import { + LEGACY_PROTECTED_OWNER, + PACKAGE_ID_RE, + PACKAGE_LEDGER_KINDS, + blockingOwners, + bundleOwner, + computeGraphDigest, + decodePackageClaimV1, + decodePackageGraphV1, + decodePackageMutationEnvelopeV1, + decodePackageRecordV1, + directUninstallVerdict, + parseOwnerToken, + standaloneOwner, + validateV3State, + withOwner, + withoutClaim, + withoutOwner, + type PackageClaimV1, + type PackageGraphV1, +} from "./ext-package-ledger-v3" + +const CONTRACT_DIR = resolve(import.meta.dir, "..", "shared", "host-extension-package-contract") +const D1 = `sha256:${"1".repeat(64)}` +const D2 = `sha256:${"2".repeat(64)}` +const D3 = `sha256:${"3".repeat(64)}` + +const graph = (over: Partial> = {}): PackageGraphV1 => { + const base = { + packageId: "skill:demo", + envelopeDigest: D1, + root: { componentId: "skill:demo", kind: "skill" as const, name: "demo", required: true, manifestDigest: D2 }, + children: [], + ...over, + } + return { ...base, graphDigest: computeGraphDigest(base) } +} + +const claim = (kind: string, name: string, owners: string[]): PackageClaimV1 => + ({ kind, name, owners: [...owners].sort() }) as PackageClaimV1 + +describe("REQ-128 #706 — package id 文法不是本模块发明的", () => { + test("轴一:与合同 schema 的 pattern 逐字相同(prelude.packageId 与组件 id 同一条)", async () => { + const schema = (await Bun.file(resolve(CONTRACT_DIR, "alpha-package-envelope-v1.schema.json")).json()) as { + properties: { + prelude: { properties: { packageId: { pattern: string } } } + components: { items: { properties: { id: { pattern: string } } } } + } + } + expect(PACKAGE_ID_RE.source).toBe(schema.properties.prelude.properties.packageId.pattern) + expect(schema.properties.components.items.properties.id.pattern).toBe(schema.properties.prelude.properties.packageId.pattern) + }) + + test("轴二:真 decoder 与本正则对同一组 id 判决逐条一致", async () => { + const corpus = (await Bun.file(resolve(CONTRACT_DIR, HOST_EXTENSION_PACKAGE_CORPUS)).json()) as { + cases: Array<{ envelope: Record }> + } + const encoder = new TextEncoder() + const template = corpus.cases[0]!.envelope + const candidates = [ + "skill:demo", // 合法 + "mcp-remote:mcp-remote-v1", // 合法(带连字符的 profile 段) + "package:a.b_c", // 下划线不在 name 段文法里 ⇒ 拒 + "Skill:demo", // 大写首字母 ⇒ 拒 + "demo", // 无冒号 ⇒ 拒 + "skill:", // 空 name ⇒ 拒 + ":demo", // 空 profile ⇒ 拒 + "skill:demo:extra", // 第二个冒号 ⇒ 拒 + "skill:-demo", // name 段首字符非字母数字 ⇒ 拒 + `skill:${"d".repeat(200)}`, // 超长 ⇒ 拒 + ] + for (const candidate of candidates) { + const envelope = structuredClone(template) + ;(envelope.prelude as Record).packageId = candidate + const decoded = decodePackageEnvelopeHeaderV1(encoder.encode(`${JSON.stringify(envelope, null, 2)}\n`)) + // decoder 拒 packageId 时报的是 header 阶段的 prelude 错误;其余错误与本轴无关。 + const decoderRejectsId = + !decoded.ok && decoded.errors.some((e) => e.includes("envelope.prelude.packageId")) + expect(decoderRejectsId, `${candidate}: decoder verdict must match PACKAGE_ID_RE`).toBe(!PACKAGE_ID_RE.test(candidate)) + } + }) + + test("child kind 集与账本 record kind 集**双向**相等", () => { + expect([...PACKAGE_LEDGER_KINDS].sort()).toEqual([...RECORD_KINDS].sort()) + }) +}) + +describe("REQ-128 #706 — owner token 解析", () => { + test("三种合法形状各自可往返", () => { + expect(parseOwnerToken(standaloneOwner("skill", "demo"))).toEqual({ kind: "standalone", childKind: "skill", childName: "demo" }) + expect(parseOwnerToken(bundleOwner("skill:demo", D2))).toEqual({ kind: "bundle", packageId: "skill:demo", manifestDigest: D2 }) + expect(parseOwnerToken(LEGACY_PROTECTED_OWNER)).toEqual({ kind: "legacy-protected" }) + }) + + test("未知形状一律 null(不是「宽容当作 legacy」)", () => { + for (const bad of [ + "", + "legacy", + "legacy-protected ", + "standalone:bogus:demo", // 未知 kind + "standalone:skill:", // 空 name + "standalone:skill:../escape", + "bundle:skill:demo", // 缺 @digest + "bundle:skill:demo@sha256:zz", // digest 文法非法 + "bundle:@" + D2, // 空 packageId + "bundle:Skill:Demo@" + D2, // packageId 文法非法 + "owner:whatever", + 42, + null, + undefined, + { kind: "standalone" }, + ]) + expect(parseOwnerToken(bad as unknown), `${JSON.stringify(bad)} must not parse`).toBeNull() + }) +}) + +describe("REQ-128 #706 — 严格解码", () => { + test("graph 往返 + graphDigest 篡改响亮失败", () => { + const g = graph() + expect(decodePackageGraphV1(g)).toEqual({ ok: true, value: g }) + const tampered = { ...g, root: { ...g.root, name: "other" } } + const bad = decodePackageGraphV1(tampered) + expect(bad.ok).toBe(false) + if (!bad.ok) expect(bad.errors[0]).toContain("does not match the graph contents") + }) + + test("graph 负向集:未知键 / 非 required root / 重复 componentId / 重复 (kind,name) / 非法 digest", () => { + const g = graph() + const cases: Array<[string, unknown]> = [ + ["unknown key", { ...g, extra: 1 }], + ["non-required root", { ...g, root: { ...g.root, required: false } }], + ["bad digest", { ...g, root: { ...g.root, manifestDigest: "sha256:nope" } }], + ["bad component id", { ...g, root: { ...g.root, componentId: "NOPE" } }], + ["children not array", { ...g, children: {} }], + ] + for (const [label, input] of cases) expect(decodePackageGraphV1(input).ok, label).toBe(false) + const dupComponent = { + packageId: "skill:demo", + envelopeDigest: D1, + root: { componentId: "skill:demo", kind: "skill", name: "demo", required: true, manifestDigest: D2 }, + children: [{ componentId: "skill:demo", kind: "agent", name: "other", required: false, manifestDigest: D3 }], + } + expect(decodePackageGraphV1({ ...dupComponent, graphDigest: computeGraphDigest(dupComponent as never) }).ok).toBe(false) + const dupChild = { + packageId: "skill:demo", + envelopeDigest: D1, + root: { componentId: "skill:demo", kind: "skill", name: "demo", required: true, manifestDigest: D2 }, + children: [{ componentId: "skill:other-id", kind: "skill", name: "demo", required: false, manifestDigest: D3 }], + } + expect(decodePackageGraphV1({ ...dupChild, graphDigest: computeGraphDigest(dupChild as never) }).ok).toBe(false) + }) + + test("claim:空 owner 集 / 未知 owner / 重复 owner / 张冠李戴的 standalone owner 一律拒", () => { + expect(decodePackageClaimV1(claim("skill", "demo", [LEGACY_PROTECTED_OWNER])).ok).toBe(true) + for (const bad of [ + { kind: "skill", name: "demo", owners: [] }, + { kind: "skill", name: "demo", owners: ["nonsense"] }, + { kind: "skill", name: "demo", owners: [LEGACY_PROTECTED_OWNER, LEGACY_PROTECTED_OWNER] }, + // standalone owner 指向另一个 child —— 一个 claim 替另一个 child 背书 + { kind: "skill", name: "demo", owners: [standaloneOwner("skill", "other")] }, + { kind: "bogus", name: "demo", owners: [LEGACY_PROTECTED_OWNER] }, + { kind: "skill", name: "demo", owners: [LEGACY_PROTECTED_OWNER], extra: 1 }, + ]) + expect(decodePackageClaimV1(bad).ok, JSON.stringify(bad)).toBe(false) + }) + + test("packageRecord:未知键 / 非法 digest / 非法 txId / 非法时间戳", () => { + const rec = { packageId: "skill:demo", envelopeDigest: D1, graphDigest: D2, transactionId: "tx-1", installedAt: "2026-07-31T00:00:00.000Z" } + expect(decodePackageRecordV1(rec).ok).toBe(true) + for (const bad of [ + { ...rec, extra: 1 }, + { ...rec, graphDigest: "nope" }, + { ...rec, transactionId: "tx/1" }, + { ...rec, installedAt: "not-a-date" }, + { ...rec, version: "" }, + ]) + expect(decodePackageRecordV1(bad).ok, JSON.stringify(bad)).toBe(false) + }) + + test("mutation envelope:install/update 必须图与记录成对且 digest 互绑;uninstall 必须两者皆 null", () => { + const g = graph() + const rec = { packageId: g.packageId, envelopeDigest: g.envelopeDigest, graphDigest: g.graphDigest, transactionId: "tx-1", installedAt: "2026-07-31T00:00:00.000Z" } + const ok = decodePackageMutationEnvelopeV1({ + operation: "install", + packageRecord: rec, + graphBeforeDigest: null, + graphAfter: g, + claimMutations: [{ op: "acquire", kind: "skill", name: "demo", owner: bundleOwner(g.packageId, D2) }], + }) + expect(ok.ok).toBe(true) + for (const bad of [ + { operation: "install", packageRecord: rec, graphBeforeDigest: null, graphAfter: null, claimMutations: [] }, + { operation: "install", packageRecord: null, graphBeforeDigest: null, graphAfter: g, claimMutations: [] }, + { operation: "uninstall", packageRecord: rec, graphBeforeDigest: g.graphDigest, graphAfter: g, claimMutations: [] }, + { operation: "install", packageRecord: { ...rec, graphDigest: D3 }, graphBeforeDigest: null, graphAfter: g, claimMutations: [] }, + { operation: "install", packageRecord: { ...rec, packageId: "skill:other" }, graphBeforeDigest: null, graphAfter: g, claimMutations: [] }, + { operation: "bogus", packageRecord: rec, graphBeforeDigest: null, graphAfter: g, claimMutations: [] }, + { operation: "install", packageRecord: rec, graphBeforeDigest: "nope", graphAfter: g, claimMutations: [] }, + { operation: "install", packageRecord: rec, graphBeforeDigest: null, graphAfter: g, claimMutations: [{ op: "acquire", kind: "skill", name: "demo", owner: "junk" }] }, + { operation: "install", packageRecord: rec, graphBeforeDigest: null, graphAfter: g, claimMutations: [], extra: 1 }, + ]) + expect(decodePackageMutationEnvelopeV1(bad).ok, JSON.stringify(bad).slice(0, 90)).toBe(false) + }) +}) + +describe("REQ-128 #706 — claim 集合代数与直接卸载判决", () => { + test("owner 集合的增删幂等,空集即删 claim", () => { + let claims = withOwner([], "skill", "demo", standaloneOwner("skill", "demo")) + claims = withOwner(claims, "skill", "demo", standaloneOwner("skill", "demo")) + expect(claims).toEqual([{ kind: "skill", name: "demo", owners: [standaloneOwner("skill", "demo")] }]) + claims = withoutOwner(claims, "skill", "demo", standaloneOwner("skill", "demo")) + expect(claims).toEqual([]) + expect(withoutClaim([claim("skill", "demo", [LEGACY_PROTECTED_OWNER])], "skill", "demo")).toEqual([]) + }) + + test("阻挡删除的只有 Bundle owner —— legacy-protected 不挡用户的显式卸载", () => { + expect(blockingOwners([LEGACY_PROTECTED_OWNER, standaloneOwner("skill", "demo")], standaloneOwner("skill", "demo"))).toEqual([]) + expect(blockingOwners([bundleOwner("skill:demo", D2)], standaloneOwner("skill", "demo"))).toEqual([bundleOwner("skill:demo", D2)]) + }) + + test("直接卸载判决:无 claim → 删;只有自己/legacy → 删;仍有 Bundle → 只释放 claim", () => { + expect(directUninstallVerdict(null, "skill", "demo")).toEqual({ decision: "delete", releasedOwner: null }) + expect(directUninstallVerdict(claim("skill", "demo", [standaloneOwner("skill", "demo")]), "skill", "demo")).toEqual({ + decision: "delete", + releasedOwner: standaloneOwner("skill", "demo"), + }) + expect(directUninstallVerdict(claim("skill", "demo", [LEGACY_PROTECTED_OWNER]), "skill", "demo")).toEqual({ + decision: "delete", + releasedOwner: null, + }) + expect( + directUninstallVerdict(claim("skill", "demo", [standaloneOwner("skill", "demo"), bundleOwner("skill:demo", D2)]), "skill", "demo"), + ).toEqual({ decision: "release-claim-only", remainingOwners: [bundleOwner("skill:demo", D2)] }) + }) +}) + +describe("REQ-128 #706 — 落盘前的整体不变量", () => { + const g = graph() + const owner = bundleOwner(g.packageId, g.root.manifestDigest) + + test("完备状态通过", () => { + expect(validateV3State({ recordKeys: new Set(["skill:demo"]), packageGraphs: [g], claims: [claim("skill", "demo", [owner])] })).toEqual({ ok: true }) + }) + + test("dangling claim:claim 指向没有 record 的 child", () => { + const r = validateV3State({ recordKeys: new Set(), packageGraphs: [], claims: [claim("skill", "demo", [LEGACY_PROTECTED_OWNER])] }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("dangling claim") + }) + + test("unknown child:图里的节点没有 claim 认领", () => { + const r = validateV3State({ recordKeys: new Set(["skill:demo"]), packageGraphs: [g], claims: [] }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("unknown child") + }) + + test("图在册但 claim 没写这个 owner —— 也是 unknown child 的一种", () => { + const r = validateV3State({ + recordKeys: new Set(["skill:demo"]), + packageGraphs: [g], + claims: [claim("skill", "demo", [LEGACY_PROTECTED_OWNER])], + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("does not carry that package as an owner") + }) + + test("孤儿 owner:claim 指名一个账本里没有的 package 图 —— 那个 owner 永远不会被释放", () => { + const r = validateV3State({ + recordKeys: new Set(["skill:demo"]), + packageGraphs: [], + claims: [claim("skill", "demo", [bundleOwner("skill:ghost", D3)])], + }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toContain("orphan owner") + }) + + test("同一个 packageId 两张图 / 同一个 child 两条 claim 都是两个真相", () => { + const dupGraph = validateV3State({ + recordKeys: new Set(["skill:demo"]), + packageGraphs: [g, g], + claims: [claim("skill", "demo", [owner])], + }) + expect(dupGraph.ok).toBe(false) + if (!dupGraph.ok) expect(dupGraph.reason).toContain("duplicate package graph") + const dupClaim = validateV3State({ + recordKeys: new Set(["skill:demo"]), + packageGraphs: [], + claims: [claim("skill", "demo", [LEGACY_PROTECTED_OWNER]), claim("skill", "demo", [LEGACY_PROTECTED_OWNER])], + }) + expect(dupClaim.ok).toBe(false) + if (!dupClaim.ok) expect(dupClaim.reason).toContain("duplicate claim") + }) +}) diff --git a/packages/ui-mac/src/main/ext-package-ledger-v3.ts b/packages/ui-mac/src/main/ext-package-ledger-v3.ts new file mode 100644 index 000000000000..841f19366fc2 --- /dev/null +++ b/packages/ui-mac/src/main/ext-package-ledger-v3.ts @@ -0,0 +1,489 @@ +// Package ledger envelope V3 — REQ-128 `#706`(已批基线 §2.9)。 +// +// V2 的账本回答「装了什么」。V3 多回答两个问题: +// · **这个 package 由哪些组件组成**(`packageGraphs`)—— root + leaf 的一张扁平图; +// · **每个组件现在归谁所有**(`claims`)—— owner **集合**,不是可漂移的整数 refcount。 +// +// 为什么是 owner 集合而不是 refcount:refcount 只要有一次漏加/漏减就永久错位,且错位之后 +// 无法自证;owner 集合每个元素都能指名道姓(哪个 Bundle、还是用户自己装的、还是 V2 时代的 +// 存量),refcount 由集合大小派生,不独立存储也就不会漂移。 +// +// owner token 三种,穷举(基线 §2.9): +// · `standalone::` —— 用户自己单装的 +// · `bundle:@` —— 某个 exact 版本的 package 装的 +// · `legacy-protected` —— V3 之前就在账本里的存量。**不猜**它是不是历史 Bundle 的 +// 一部分,所以它永远不被自动回收。 +// +// 本模块是**纯**的:只有类型、严格解码器与集合代数,零 fs、零 electron。账本文件的读写 +// 仍然只有 `ext-receipt-v2.ts` 一个物理写器(V3 落地时同时删掉了第二个物理写器 +// `alpha-installs.writeLedger` 的全部生产调用)。 + +import { isExtensionName } from "../shared/extension-name" +import { canonicalJson, sha256Hex } from "./ext-manifest-v2" +import type { InstallReceiptType } from "../preload/types" +import type { UpsertInput } from "./ext-receipt-v2" + +/** V3 信封版本号。`v: 3` 的账本对只懂 V2 的构建是「读不懂的数据」—— + * `ext-receipt-v2.parseLedger` 早已对 v ∉ {1,2} 拒触碰,所以 downgrade 天然 fail-closed。 */ +export const PACKAGE_LEDGER_ENVELOPE_VERSION = 3 as const + +/** V3 图/claim 里允许出现的 child kind。与 `ext-receipt-v2` 的 record kind 集必须逐字相等 —— + * 由 `ext-package-ledger-v3.test.ts` 对着导出的 `RECORD_KINDS` 双向断言(少一个 = 该 kind + * 的 child 无法被 claim 保护;多一个 = claim 指向一个不可能存在的 record)。 */ +export const PACKAGE_LEDGER_KINDS = new Set(["mcp", "skill", "agent", "command", "plugin", "bundle", "cloud"]) + +const DIGEST_RE = /^sha256:[0-9a-f]{64}$/ +/** + * package id 与组件 id 的文法。真源是宿主合同 schema + * `alpha-package-envelope-v1.schema.json`(`prelude.packageId.pattern`,组件 `id` 同 pattern), + * 它与 `decoder.ts` 一起被钉进跨仓 artifact —— 本票不得改动那些字节,所以这里只能带一份副本。 + * + * 副本 = 替别人写文法,是本仓最贵的返工形态。因此 `ext-package-ledger-v3.test.ts` 用**两条 + * 互相独立的轴**盯住它:①逐字比对 schema 里的 pattern 字符串;②把同一组 id 同时喂给真 decoder + * 与本正则,断言接受/拒绝逐条一致。任一轴不合 = 红。 + */ +export const PACKAGE_ID_RE = /^[a-z][a-z0-9-]{0,31}:[a-z0-9][a-z0-9._-]{0,127}$/ +/** 组件 id 与 package id 同文法。 */ +const COMPONENT_ID_RE = PACKAGE_ID_RE +const TX_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ +const LEGACY_PROTECTED = "legacy-protected" + +export type PackageChildRefV1 = { kind: InstallReceiptType; name: string } + +export type PackageGraphNodeV1 = { + /** envelope 组件 id(`:` 形态;本层只做文法校验,不解释语义)。 */ + componentId: string + kind: InstallReceiptType + name: string + required: boolean + manifestDigest: string +} + +/** 一个 package 的**有效安装图**:root 一个、leaf 若干(本期 leaf 恒空,`#697` 起非空)。 + * `graphDigest` 覆盖 packageId + envelopeDigest + root + children,是 preview / 重验 / + * claim mutation 三者共用的绑定值(篡改任一节点 → digest 不符 → 响亮失败)。 */ +export type PackageGraphV1 = { + packageId: string + envelopeDigest: string + graphDigest: string + root: PackageGraphNodeV1 + children: PackageGraphNodeV1[] +} + +/** 「这个 package 装过,当前这一代长这样」。删除 package 时连同它的图一起消失。 */ +export type PackageRecordV1 = { + packageId: string + envelopeDigest: string + graphDigest: string + version?: string + transactionId: string + installedAt: string + updatedAt?: string +} + +/** 一个 child 的 owner 集合。`owners` 排序去重且**非空** —— 空集不是「没人要」, + * 空集根本不该落盘(该删 claim 了)。 */ +export type PackageClaimV1 = { + kind: InstallReceiptType + name: string + owners: string[] +} + +export type ChildRecordMutationV1 = + | { op: "upsert"; input: UpsertInput } + | { op: "remove"; kind: InstallReceiptType; name: string } + +export type ClaimMutationV1 = { + op: "acquire" | "release" + kind: InstallReceiptType + name: string + owner: string +} + +/** 唯一的事务提交对象(基线 §2.9)。只挂在 **root** package item 上;child item 各自持 + * capabilities 与 probe,但不独立驱动落账 —— 否则「child 已 durable、graph/claims 缺失」 + * 这种半态就会出现,而它恰恰是 owner 集合无法自证的那一种。 */ +export type PackageLedgerMutationV1 = { + transactionId: string + operation: "install" | "update" | "uninstall" + packageRecord: PackageRecordV1 | null + graphBeforeDigest: string | null + graphAfter: PackageGraphV1 | null + childRecordMutations: ChildRecordMutationV1[] + claimMutations: ClaimMutationV1[] +} + +/** journal 里持久化的静态半场(childRecordMutations 在 commit 时由 commit records 派生, + * 主提交与崩溃前滚同源 —— 两条路径算出同一份 mutation 才谈得上 exact replay)。 */ +export type PackageMutationEnvelopeV1 = Omit + +// ── owner token ──────────────────────────────────────────────────────────────────────────────── + +export const standaloneOwner = (kind: string, name: string): string => `standalone:${kind}:${name}` +export const bundleOwner = (packageId: string, manifestDigest: string): string => `bundle:${packageId}@${manifestDigest}` +export const LEGACY_PROTECTED_OWNER = LEGACY_PROTECTED + +export type OwnerToken = + | { kind: "standalone"; childKind: string; childName: string } + | { kind: "bundle"; packageId: string; manifestDigest: string } + | { kind: "legacy-protected" } + +/** owner token 的严格解析。**不认得的形状一律 null** —— 未知 owner 既不能被释放也不能被 + * 证明为空,放行等于把「还有别人要」和「谁都不要」混成一个格子。 */ +export function parseOwnerToken(token: unknown): OwnerToken | null { + if (typeof token !== "string" || token.length === 0 || token.length > 512) return null + if (token === LEGACY_PROTECTED) return { kind: "legacy-protected" } + if (token.startsWith("standalone:")) { + const rest = token.slice("standalone:".length) + const sep = rest.indexOf(":") + if (sep <= 0) return null + const childKind = rest.slice(0, sep) + const childName = rest.slice(sep + 1) + if (!PACKAGE_LEDGER_KINDS.has(childKind) || !isExtensionName(childName)) return null + return { kind: "standalone", childKind, childName } + } + if (token.startsWith("bundle:")) { + const rest = token.slice("bundle:".length) + const at = rest.indexOf("@") + if (at <= 0) return null + const packageId = rest.slice(0, at) + const manifestDigest = rest.slice(at + 1) + if (!PACKAGE_ID_RE.test(packageId) || !DIGEST_RE.test(manifestDigest)) return null + return { kind: "bundle", packageId, manifestDigest } + } + return null +} + +// ── 严格解码 ─────────────────────────────────────────────────────────────────────────────────── + +const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v) + +const GRAPH_NODE_KEYS = new Set(["componentId", "kind", "name", "required", "manifestDigest"]) +const GRAPH_KEYS = new Set(["packageId", "envelopeDigest", "graphDigest", "root", "children"]) +const CLAIM_KEYS = new Set(["kind", "name", "owners"]) +const PACKAGE_RECORD_KEYS = new Set(["packageId", "envelopeDigest", "graphDigest", "version", "transactionId", "installedAt", "updatedAt"]) + +export type Decoded = { ok: true; value: T } | { ok: false; errors: string[] } + +function decodeGraphNode(input: unknown, at: string, errors: string[]): PackageGraphNodeV1 | null { + if (!isObj(input)) { + errors.push(`${at}: must be an object`) + return null + } + for (const k of Object.keys(input)) if (!GRAPH_NODE_KEYS.has(k)) errors.push(`${at}: unknown key "${k}" — refused (strict schema)`) + const componentId = input.componentId + const kind = input.kind + const name = input.name + const required = input.required + const manifestDigest = input.manifestDigest + if (typeof componentId !== "string" || !COMPONENT_ID_RE.test(componentId)) errors.push(`${at}.componentId: invalid`) + if (typeof kind !== "string" || !PACKAGE_LEDGER_KINDS.has(kind)) errors.push(`${at}.kind: invalid`) + if (typeof name !== "string" || !isExtensionName(name)) errors.push(`${at}.name: invalid`) + if (typeof required !== "boolean") errors.push(`${at}.required: must be a boolean`) + if (typeof manifestDigest !== "string" || !DIGEST_RE.test(manifestDigest)) errors.push(`${at}.manifestDigest: invalid`) + if (errors.length > 0) return null + return { + componentId: componentId as string, + kind: kind as InstallReceiptType, + name: name as string, + required: required as boolean, + manifestDigest: manifestDigest as string, + } +} + +/** graphDigest 的计算口径:packageId + envelopeDigest + root + children(children 按 + * componentId 排序)。**digest 本身不参与计算** —— 否则自指。 */ +export function computeGraphDigest(graph: Omit): string { + return `sha256:${sha256Hex( + canonicalJson({ + packageId: graph.packageId, + envelopeDigest: graph.envelopeDigest, + root: graph.root, + children: [...graph.children].sort((a, b) => (a.componentId < b.componentId ? -1 : a.componentId > b.componentId ? 1 : 0)), + }), + )}` +} + +export function decodePackageGraphV1(input: unknown): Decoded { + const errors: string[] = [] + if (!isObj(input)) return { ok: false, errors: ["packageGraph: must be an object"] } + for (const k of Object.keys(input)) if (!GRAPH_KEYS.has(k)) errors.push(`packageGraph: unknown key "${k}" — refused (strict schema)`) + const packageId = input.packageId + const envelopeDigest = input.envelopeDigest + const graphDigest = input.graphDigest + if (typeof packageId !== "string" || !PACKAGE_ID_RE.test(packageId)) errors.push("packageGraph.packageId: invalid") + if (typeof envelopeDigest !== "string" || !DIGEST_RE.test(envelopeDigest)) errors.push("packageGraph.envelopeDigest: invalid") + if (typeof graphDigest !== "string" || !DIGEST_RE.test(graphDigest)) errors.push("packageGraph.graphDigest: invalid") + const root = decodeGraphNode(input.root, "packageGraph.root", errors) + if (!Array.isArray(input.children)) errors.push("packageGraph.children: must be an array") + const children: PackageGraphNodeV1[] = [] + if (Array.isArray(input.children)) + for (let i = 0; i < input.children.length; i++) { + const node = decodeGraphNode(input.children[i], `packageGraph.children[${i}]`, errors) + if (node) children.push(node) + } + if (errors.length > 0 || !root) return { ok: false, errors: errors.length ? errors : ["packageGraph.root: invalid"] } + // root 必须是 required(基线:非 required 的 root 无意义 —— 整包可跳过就不是一个包)。 + if (!root.required) return { ok: false, errors: ["packageGraph.root.required: root component must be required"] } + // 组件 id / (kind,name) 全局唯一 —— 重复 id 会让 claim 归属出现两个答案。 + const ids = new Set() + const keys = new Set() + for (const node of [root, ...children]) { + if (ids.has(node.componentId)) return { ok: false, errors: [`packageGraph: duplicate componentId "${node.componentId}"`] } + ids.add(node.componentId) + const k = `${node.kind}:${node.name}` + if (keys.has(k)) return { ok: false, errors: [`packageGraph: duplicate child ${k}`] } + keys.add(k) + } + const value: PackageGraphV1 = { + packageId: packageId as string, + envelopeDigest: envelopeDigest as string, + graphDigest: graphDigest as string, + root, + children, + } + // 篡改闸:任何节点被改过 → 重算 digest 不符 → 拒。 + const recomputed = computeGraphDigest(value) + if (recomputed !== value.graphDigest) + return { ok: false, errors: [`packageGraph.graphDigest: does not match the graph contents (expected ${recomputed})`] } + return { ok: true, value } +} + +export function decodePackageClaimV1(input: unknown): Decoded { + const errors: string[] = [] + if (!isObj(input)) return { ok: false, errors: ["claim: must be an object"] } + for (const k of Object.keys(input)) if (!CLAIM_KEYS.has(k)) errors.push(`claim: unknown key "${k}" — refused (strict schema)`) + const kind = input.kind + const name = input.name + if (typeof kind !== "string" || !PACKAGE_LEDGER_KINDS.has(kind)) errors.push("claim.kind: invalid") + if (typeof name !== "string" || !isExtensionName(name)) errors.push("claim.name: invalid") + if (!Array.isArray(input.owners) || input.owners.length === 0) errors.push("claim.owners: must be a non-empty array") + const owners: string[] = [] + if (Array.isArray(input.owners)) + for (const owner of input.owners) { + if (parseOwnerToken(owner) === null) { + errors.push(`claim.owners: unrecognised owner token ${JSON.stringify(owner)} — refused (fail closed)`) + continue + } + if (owners.includes(owner as string)) { + errors.push(`claim.owners: duplicate owner ${JSON.stringify(owner)}`) + continue + } + owners.push(owner as string) + } + if (errors.length > 0) return { ok: false, errors } + // standalone owner 必须指向 claim 自己那个 child —— 否则一个 child 的 claim 能替另一个背书。 + for (const owner of owners) { + const parsed = parseOwnerToken(owner)! + if (parsed.kind === "standalone" && (parsed.childKind !== kind || parsed.childName !== name)) + return { ok: false, errors: [`claim.owners: standalone owner ${owner} does not match ${String(kind)}:${String(name)}`] } + } + return { ok: true, value: { kind: kind as InstallReceiptType, name: name as string, owners: [...owners].sort() } } +} + +export function decodePackageRecordV1(input: unknown): Decoded { + const errors: string[] = [] + if (!isObj(input)) return { ok: false, errors: ["packageRecord: must be an object"] } + for (const k of Object.keys(input)) if (!PACKAGE_RECORD_KEYS.has(k)) errors.push(`packageRecord: unknown key "${k}" — refused (strict schema)`) + const { packageId, envelopeDigest, graphDigest, version, transactionId, installedAt, updatedAt } = input + if (typeof packageId !== "string" || !PACKAGE_ID_RE.test(packageId)) errors.push("packageRecord.packageId: invalid") + if (typeof envelopeDigest !== "string" || !DIGEST_RE.test(envelopeDigest)) errors.push("packageRecord.envelopeDigest: invalid") + if (typeof graphDigest !== "string" || !DIGEST_RE.test(graphDigest)) errors.push("packageRecord.graphDigest: invalid") + if (version !== undefined && (typeof version !== "string" || version.length === 0 || version.length > 128)) errors.push("packageRecord.version: invalid") + if (typeof transactionId !== "string" || !TX_ID_RE.test(transactionId)) errors.push("packageRecord.transactionId: invalid") + if (typeof installedAt !== "string" || Number.isNaN(Date.parse(installedAt))) errors.push("packageRecord.installedAt: invalid") + if (updatedAt !== undefined && (typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt)))) errors.push("packageRecord.updatedAt: invalid") + if (errors.length > 0) return { ok: false, errors } + return { + ok: true, + value: { + packageId: packageId as string, + envelopeDigest: envelopeDigest as string, + graphDigest: graphDigest as string, + ...(version !== undefined ? { version: version as string } : {}), + transactionId: transactionId as string, + installedAt: installedAt as string, + ...(updatedAt !== undefined ? { updatedAt: updatedAt as string } : {}), + }, + } +} + +const ENVELOPE_KEYS = new Set(["operation", "packageRecord", "graphBeforeDigest", "graphAfter", "claimMutations"]) + +/** journal 里那半场的严格解码(不含 transactionId / childRecordMutations —— 那两样在 + * commit 时由事务与 commit records 提供,不接受调用方自带)。 */ +export function decodePackageMutationEnvelopeV1(input: unknown): Decoded { + const errors: string[] = [] + if (!isObj(input)) return { ok: false, errors: ["packageMutation: must be an object"] } + for (const k of Object.keys(input)) if (!ENVELOPE_KEYS.has(k)) errors.push(`packageMutation: unknown key "${k}" — refused (strict schema)`) + const operation = input.operation + if (operation !== "install" && operation !== "update" && operation !== "uninstall") errors.push("packageMutation.operation: invalid") + let packageRecord: PackageRecordV1 | null = null + if (input.packageRecord !== null) { + const decoded = decodePackageRecordV1(input.packageRecord) + if (!decoded.ok) errors.push(...decoded.errors) + else packageRecord = decoded.value + } + let graphAfter: PackageGraphV1 | null = null + if (input.graphAfter !== null) { + const decoded = decodePackageGraphV1(input.graphAfter) + if (!decoded.ok) errors.push(...decoded.errors) + else graphAfter = decoded.value + } + const graphBeforeDigest = input.graphBeforeDigest + if (graphBeforeDigest !== null && (typeof graphBeforeDigest !== "string" || !DIGEST_RE.test(graphBeforeDigest))) + errors.push("packageMutation.graphBeforeDigest: must be null or a sha256 digest") + const claimMutations: ClaimMutationV1[] = [] + if (!Array.isArray(input.claimMutations)) errors.push("packageMutation.claimMutations: must be an array") + else + for (let i = 0; i < input.claimMutations.length; i++) { + const raw = input.claimMutations[i] + if (!isObj(raw)) { + errors.push(`packageMutation.claimMutations[${i}]: must be an object`) + continue + } + for (const k of Object.keys(raw)) if (!["op", "kind", "name", "owner"].includes(k)) errors.push(`packageMutation.claimMutations[${i}]: unknown key "${k}"`) + if (raw.op !== "acquire" && raw.op !== "release") errors.push(`packageMutation.claimMutations[${i}].op: invalid`) + if (typeof raw.kind !== "string" || !PACKAGE_LEDGER_KINDS.has(raw.kind)) errors.push(`packageMutation.claimMutations[${i}].kind: invalid`) + if (typeof raw.name !== "string" || !isExtensionName(raw.name)) errors.push(`packageMutation.claimMutations[${i}].name: invalid`) + if (parseOwnerToken(raw.owner) === null) errors.push(`packageMutation.claimMutations[${i}].owner: unrecognised owner token`) + if (errors.length === 0) + claimMutations.push({ + op: raw.op as "acquire" | "release", + kind: raw.kind as InstallReceiptType, + name: raw.name as string, + owner: raw.owner as string, + }) + } + if (errors.length > 0) return { ok: false, errors } + // packageRecord 与 graphAfter 必须互相绑定:install/update 两者都在且 digest 一致; + // uninstall 两者都为 null。半套 = 「child 已 durable 但 graph 缺失」的另一种写法。 + if (operation === "uninstall") { + if (packageRecord !== null || graphAfter !== null) + return { ok: false, errors: ["packageMutation: uninstall must carry packageRecord=null and graphAfter=null"] } + } else { + if (packageRecord === null || graphAfter === null) + return { ok: false, errors: ["packageMutation: install/update must carry both packageRecord and graphAfter"] } + if (packageRecord.graphDigest !== graphAfter.graphDigest) + return { ok: false, errors: ["packageMutation: packageRecord.graphDigest does not match graphAfter.graphDigest"] } + if (packageRecord.packageId !== graphAfter.packageId) + return { ok: false, errors: ["packageMutation: packageRecord.packageId does not match graphAfter.packageId"] } + if (packageRecord.envelopeDigest !== graphAfter.envelopeDigest) + return { ok: false, errors: ["packageMutation: packageRecord.envelopeDigest does not match graphAfter.envelopeDigest"] } + } + return { + ok: true, + value: { + operation: operation as "install" | "update" | "uninstall", + packageRecord, + graphBeforeDigest: (graphBeforeDigest ?? null) as string | null, + graphAfter, + claimMutations, + }, + } +} + +// ── claim 集合代数 ───────────────────────────────────────────────────────────────────────────── + +const claimKey = (kind: string, name: string) => `${kind}:${name}` + +export function findClaim(claims: readonly PackageClaimV1[], kind: string, name: string): PackageClaimV1 | null { + return claims.find((c) => c.kind === kind && c.name === name) ?? null +} + +/** 阻挡删除的 owner = 仍在使用它的 **Bundle**。`legacy-protected` 不阻挡用户的显式直接卸载 + * (那是用户自己要求的),但它阻挡任何自动 GC —— 两者是不同的问题,别合并成一个判据。 */ +export const blockingOwners = (owners: readonly string[], excluding: string): string[] => + owners.filter((o) => o !== excluding && parseOwnerToken(o)?.kind === "bundle") + +export type DirectUninstallVerdict = + | { decision: "delete"; releasedOwner: string | null } + | { decision: "release-claim-only"; remainingOwners: string[] } + +/** 直接(用户发起的)卸载判决。**必须在删任何实物之前调用** —— repository 事后拒绝时实物 + * 已经没了,用户看到「卸载失败」而东西是真没了,这正是 V3 要消灭的那种半态。 */ +export function directUninstallVerdict(claim: PackageClaimV1 | null, kind: string, name: string): DirectUninstallVerdict { + if (!claim) return { decision: "delete", releasedOwner: null } + const own = standaloneOwner(kind, name) + const blocking = blockingOwners(claim.owners, own) + if (blocking.length > 0) return { decision: "release-claim-only", remainingOwners: [...claim.owners.filter((o) => o !== own)].sort() } + return { decision: "delete", releasedOwner: claim.owners.includes(own) ? own : null } +} + +export function withOwner(claims: readonly PackageClaimV1[], kind: InstallReceiptType, name: string, owner: string): PackageClaimV1[] { + const next = claims.filter((c) => claimKey(c.kind, c.name) !== claimKey(kind, name)) + const existing = findClaim(claims, kind, name) + const owners = existing ? [...new Set([...existing.owners, owner])].sort() : [owner] + next.push({ kind, name, owners }) + return next +} + +/** 释放一个 owner。owner 集合空掉 = 这个 claim 该消失(空集不落盘)。 */ +export function withoutOwner(claims: readonly PackageClaimV1[], kind: string, name: string, owner: string): PackageClaimV1[] { + const existing = findClaim(claims, kind, name) + if (!existing) return [...claims] + const owners = existing.owners.filter((o) => o !== owner) + const rest = claims.filter((c) => claimKey(c.kind, c.name) !== claimKey(kind, name)) + return owners.length === 0 ? rest : [...rest, { ...existing, owners: [...owners].sort() }] +} + +/** 整条 claim 拿掉(用户显式直接卸载走这条:连 legacy-protected 一起走,因为 record 也没了)。 */ +export const withoutClaim = (claims: readonly PackageClaimV1[], kind: string, name: string): PackageClaimV1[] => + claims.filter((c) => claimKey(c.kind, c.name) !== claimKey(kind, name)) + +// ── 不变量 ───────────────────────────────────────────────────────────────────────────────────── + +export type V3State = { + recordKeys: ReadonlySet + packageGraphs: readonly PackageGraphV1[] + claims: readonly PackageClaimV1[] +} + +/** + * 落盘**之前**的整体不变量。任何一条不成立就拒写并原样保留旧文件 —— + * 这些都不是「数据不好看」,而是 owner 集合从此无法自证: + * + * 1. **dangling claim**:claim 指向一个没有 record 的 child —— 保护一个不存在的东西, + * 而它会永远挡住同名 child 的将来安装。 + * 2. **unknown child**:图里的 leaf 没有对应 claim —— 这个 leaf 归谁没人知道。 + * 3. **孤儿 bundle owner**:claim 里写着某个 `bundle:pkg@digest`,但账本里没有这张图 —— + * 那个 owner 永远不会被释放,child 从此不可回收。 + * 4. **packageId / graphDigest 重复**:一个 package 两张图 = 两个真相。 + */ +export function validateV3State(state: V3State): { ok: true } | { ok: false; reason: string } { + const seenPackages = new Set() + const graphOwners = new Set() + for (const graph of state.packageGraphs) { + if (seenPackages.has(graph.packageId)) return { ok: false, reason: `duplicate package graph for "${graph.packageId}"` } + seenPackages.add(graph.packageId) + graphOwners.add(bundleOwner(graph.packageId, graph.root.manifestDigest)) + for (const node of [graph.root, ...graph.children]) { + const claim = findClaim(state.claims, node.kind, node.name) + if (!claim) + return { ok: false, reason: `package "${graph.packageId}" graph names ${node.kind}:${node.name} but no claim owns it (unknown child — fail closed)` } + if (!claim.owners.includes(bundleOwner(graph.packageId, graph.root.manifestDigest))) + return { + ok: false, + reason: `package "${graph.packageId}" graph names ${node.kind}:${node.name} but its claim does not carry that package as an owner`, + } + } + } + const seenClaims = new Set() + for (const claim of state.claims) { + const k = claimKey(claim.kind, claim.name) + if (seenClaims.has(k)) return { ok: false, reason: `duplicate claim for ${k}` } + seenClaims.add(k) + if (claim.owners.length === 0) return { ok: false, reason: `claim ${k} has an empty owner set — it must not exist` } + if (!state.recordKeys.has(k)) return { ok: false, reason: `dangling claim ${k}: no install record owns this child (fail closed)` } + for (const owner of claim.owners) { + const parsed = parseOwnerToken(owner) + if (!parsed) return { ok: false, reason: `claim ${k} carries an unrecognised owner token ${JSON.stringify(owner)}` } + if (parsed.kind === "bundle" && !graphOwners.has(owner)) + return { ok: false, reason: `claim ${k} names owner ${owner} but no package graph in this ledger matches it (orphan owner — fail closed)` } + } + } + return { ok: true } +} + +/** V3 段是否为空(空 = 账本仍写 V2 信封,老构建照常可用)。 */ +export const isV3Active = (graphs: readonly unknown[], claims: readonly unknown[]): boolean => graphs.length > 0 || claims.length > 0 diff --git a/packages/ui-mac/src/main/ext-receipt-v2.test.ts b/packages/ui-mac/src/main/ext-receipt-v2.test.ts index a8cf9eb5115c..01f1337e739e 100644 --- a/packages/ui-mac/src/main/ext-receipt-v2.test.ts +++ b/packages/ui-mac/src/main/ext-receipt-v2.test.ts @@ -396,7 +396,7 @@ describe("v1 → v2 explicit migration (AC#6)", () => { test("#357 Blocker 回归锁:未来版本/未知顶层键/非数组集合 → 拒迁移,原文件字节零改动", () => { const cases = [ - { v: 3, receipts: [] }, // 未来版本 + { v: 4, receipts: [] }, // 未来版本(v:3 是本构建的 V3 信封,REQ-128 #706) { v: 1, receipts: [], futureKey: {} }, // 未知顶层键 { v: 1, receipts: {} }, // receipts 非数组(parseLedger 会静默当空 → 重写即丢数据) { v: 2, receipts: [], records: "oops" }, // records 非数组 @@ -508,7 +508,7 @@ describe("#378 r17 Blocker —— 损坏记录原文保全 + 同 key/unattributa }) test("r22:信封收口 —— 未来版本一切写拒零触碰;records 非数组按损坏处理(quarantine 保字节,不静默折叠成空)", () => { - fs.writeFileSync(ledgerFile(), JSON.stringify({ v: 3, receipts: [], records: [{ future: true }] })) + fs.writeFileSync(ledgerFile(), JSON.stringify({ v: 4, receipts: [], records: [{ future: true }] })) const futureBytes = fs.readFileSync(ledgerFile(), "utf8") expect(probeLedgerForWrite(root).ok).toBe(false) const w = upsertRecordV2(root, upsertInput()) diff --git a/packages/ui-mac/src/main/ext-receipt-v2.ts b/packages/ui-mac/src/main/ext-receipt-v2.ts index 473a0dea7557..6eaf5b6d784a 100644 --- a/packages/ui-mac/src/main/ext-receipt-v2.ts +++ b/packages/ui-mac/src/main/ext-receipt-v2.ts @@ -28,6 +28,24 @@ import type { AppEnvironment } from "./alpha-environment" import { canonicalJson, sha256Hex } from "./ext-manifest-v2" import { validateReceipt } from "./alpha-installs" import { writeFileAtomicSync } from "./ext-atomic-fs" +import { + LEGACY_PROTECTED_OWNER, + PACKAGE_LEDGER_ENVELOPE_VERSION, + bundleOwner, + decodePackageClaimV1, + decodePackageGraphV1, + directUninstallVerdict, + findClaim, + isV3Active, + standaloneOwner, + validateV3State, + withOwner, + withoutClaim, + withoutOwner, + type PackageClaimV1, + type PackageGraphV1, + type PackageLedgerMutationV1, +} from "./ext-package-ledger-v3" export const RECORD_SCHEMA_VERSION = 2 as const @@ -76,6 +94,8 @@ export interface InstallRecordV2 { const LEDGER_FILE = "installs.json" const KINDS = new Set(["mcp", "skill", "agent", "command", "plugin", "bundle", "cloud"]) +/** V3 的 claim/graph 只能指向这些 kind。导出供 `ext-package-ledger-v3.test.ts` 双向对齐断言。 */ +export const RECORD_KINDS: ReadonlySet = KINDS const ORIGINS = new Set(["catalog", "created", "imported", "imported-claude", "imported-agents"]) const ENVIRONMENTS = new Set(["prod", "beta", "dev"]) const DESIRED = new Set(["enabled", "disabled"]) @@ -309,6 +329,9 @@ type ParsedLedger = { * 操作仍被各闸拒绝;保全只保证证据不因无关写入蒸发。 */ rawInvalidReceipts: unknown[] rawCorruptRecords: unknown[] + /** REQ-128 `#706`:V3 段。空 = 本账本还没装过 package(信封仍写 v:2)。 */ + packageGraphs: PackageGraphV1[] + claims: PackageClaimV1[] } /** 从损坏 record 原始对象里独立提取 kind:name(不经严格 decoder)。两者都是合法字符串且 kind 已知 @@ -346,16 +369,52 @@ export function probeLedgerForWrite(root: string): { ok: true } | { ok: false; r if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, reason: `install ledger corrupt (non-object root) — refusing to write; inspect ${ledgerPath(root)}` } // r22 Major:提交面同样收信封 —— 未来版本拒写(读不懂的数据不得重建);records/receipts - // 非数组 = 损坏,拒(quarantine 不是提交路径)。 - if ("v" in raw && raw.v !== undefined && raw.v !== 1 && raw.v !== 2) { + // 非数组 = 损坏,拒(quarantine 不是提交路径)。REQ-128 `#706`:v:3 是本构建的 V3 信封。 + if ("v" in raw && raw.v !== undefined && raw.v !== 1 && raw.v !== 2 && raw.v !== PACKAGE_LEDGER_ENVELOPE_VERSION) { const vLabel = typeof raw.v === "number" || typeof raw.v === "string" ? raw.v : "unknown" return { ok: false, reason: `install ledger envelope version ${vLabel} is newer than this build understands — refusing to write; inspect ${ledgerPath(root)}` } } if (("records" in raw && raw.records !== undefined && !Array.isArray(raw.records)) || ("receipts" in raw && raw.receipts !== undefined && !Array.isArray(raw.receipts))) return { ok: false, reason: `install ledger corrupt (non-array records/receipts) — refusing to write; inspect ${ledgerPath(root)}` } + const v3 = envelopeV3Sections(raw as Record, ledgerPath(root)) + if (!v3.ok) return { ok: false, reason: `${v3.reason} — refusing to write` } return { ok: true } } +/** REQ-128 `#706`:V3 段的信封级严格读取。**任何一条解不开就整本拒** —— + * claim 决定「这个 child 还能不能删」,解不开的 claim 无法证明它说的不是当前这个 child, + * 所以这里没有「排除坏条目继续用」这条路(那正是 owner 集合被悄悄削弱的方式)。 + * `v` 与 V3 段必须自洽:v:1/v:2 却带着非空 packageGraphs/claims = 被改过或被半写过。 */ +function envelopeV3Sections( + raw: Record, + file: string, +): { ok: true; packageGraphs: PackageGraphV1[]; claims: PackageClaimV1[] } | { ok: false; reason: string } { + const rawGraphs = raw.packageGraphs + const rawClaims = raw.claims + if (rawGraphs !== undefined && !Array.isArray(rawGraphs)) return { ok: false, reason: `install ledger corrupt (packageGraphs is not an array): ${file}` } + if (rawClaims !== undefined && !Array.isArray(rawClaims)) return { ok: false, reason: `install ledger corrupt (claims is not an array): ${file}` } + const graphList = Array.isArray(rawGraphs) ? rawGraphs : [] + const claimList = Array.isArray(rawClaims) ? rawClaims : [] + if (isV3Active(graphList, claimList) && raw.v !== PACKAGE_LEDGER_ENVELOPE_VERSION) + return { + ok: false, + reason: `install ledger declares envelope v${String(raw.v)} but carries package graphs/claims (V3 sections require v${PACKAGE_LEDGER_ENVELOPE_VERSION}): ${file}`, + } + const packageGraphs: PackageGraphV1[] = [] + for (const entry of graphList) { + const decoded = decodePackageGraphV1(entry) + if (!decoded.ok) return { ok: false, reason: `install ledger package graph rejected (fail closed): ${decoded.errors[0]}: ${file}` } + packageGraphs.push(decoded.value) + } + const claims: PackageClaimV1[] = [] + for (const entry of claimList) { + const decoded = decodePackageClaimV1(entry) + if (!decoded.ok) return { ok: false, reason: `install ledger claim rejected (fail closed): ${decoded.errors[0]}: ${file}` } + claims.push(decoded.value) + } + return { ok: true, packageGraphs, claims } +} + /** r19 Major:readError = 文件在场但读失败(EIO/EACCES 等瞬时故障)—— 绝不折叠成「健康空账」, * 否则 removeRecordV2 会 no-op 成功、卸载在记录仍在的情况下谎报完成。缺席(ENOENT/ENOTDIR) * 才是合法空账。 */ @@ -368,6 +427,8 @@ function parseLedger(root: string): { parsed: ParsedLedger; corrupt: boolean; re corruptRecords: { corruptKeys: new Set(), unattributable: false }, rawInvalidReceipts: [], rawCorruptRecords: [], + packageGraphs: [], + claims: [], } let text: string try { @@ -387,12 +448,16 @@ function parseLedger(root: string): { parsed: ParsedLedger; corrupt: boolean; re // r22 Major:信封收口 —— 未来版本信封(v 非 1/2)拒触碰(不是损坏,是本构建读不懂的数据, // 重建 = 摧毁);records/receipts 非数组不得折叠成「空」(下一次 upsert 整文件重建会连同 // 所有权证据一起抹掉)—— 按损坏文件处理(写路径 quarantine 保字节 + 响亮警告)。 - if (raw.v !== undefined && raw.v !== 1 && raw.v !== 2) { + if (raw.v !== undefined && raw.v !== 1 && raw.v !== 2 && raw.v !== PACKAGE_LEDGER_ENVELOPE_VERSION) { const vLabel = typeof raw.v === "number" || typeof raw.v === "string" ? raw.v : "unknown" return { parsed: empty, corrupt: false, readError: `install ledger envelope version ${vLabel} is newer than this build understands — refusing to touch it: ${ledgerPath(root)}` } } if ((raw.records !== undefined && !Array.isArray(raw.records)) || (raw.receipts !== undefined && !Array.isArray(raw.receipts))) return { parsed: empty, corrupt: true } + // REQ-128 `#706`:V3 段解不开 = readError(不是 corrupt)—— 文件原样不动、所有写路径拒绝。 + // 走 quarantine 会把 owner 事实连同证据一起搬走,而 claim 恰恰是「这东西还能不能删」的唯一凭据。 + const v3 = envelopeV3Sections(raw, ledgerPath(root)) + if (!v3.ok) return { parsed: empty, corrupt: false, readError: v3.reason } const receipts: InstallReceipt[] = [] const receiptWarnings: string[] = [] const rawInvalidReceipts: unknown[] = [] @@ -457,7 +522,17 @@ function parseLedger(root: string): { parsed: ParsedLedger; corrupt: boolean; re } } return { - parsed: { receipts, records, recordWarnings, receiptWarnings, corruptRecords: { corruptKeys, unattributable }, rawInvalidReceipts, rawCorruptRecords }, + parsed: { + receipts, + records, + recordWarnings, + receiptWarnings, + corruptRecords: { corruptKeys, unattributable }, + rawInvalidReceipts, + rawCorruptRecords, + packageGraphs: v3.packageGraphs, + claims: v3.claims, + }, corrupt: false, } } @@ -574,8 +649,19 @@ function writeLedgerFile( root: string, receipts: readonly unknown[], records: readonly unknown[], + v3: { packageGraphs: readonly PackageGraphV1[]; claims: readonly PackageClaimV1[] }, publishFinal: SkillsFinalPublish = writeFileAtomicSync, ): { ok: true; projectionLag?: string } | { ok: false; reason: string } { + // REQ-128 `#706`:V3 不变量在**落盘之前**判 —— 一次校验、一次 rename。dangling claim / + // unknown child / 孤儿 owner 一旦 durable 就再也无法自证,所以这里宁可整次拒写。 + // recordKeys 只认已解码的 record(损坏条目由各闸单独拒绝,不能替 claim 背书)。 + const recordKeys = new Set() + for (const r of records) { + const rec = r as { kind?: unknown; name?: unknown; schemaVersion?: unknown } + if (typeof rec?.kind === "string" && typeof rec?.name === "string" && rec.schemaVersion === RECORD_SCHEMA_VERSION) recordKeys.add(key(rec.kind, rec.name)) + } + const invariants = validateV3State({ recordKeys, packageGraphs: v3.packageGraphs, claims: v3.claims }) + if (!invariants.ok) return { ok: false, reason: `refusing ledger write: ${invariants.reason}` } try { fs.mkdirSync(root, { recursive: true }) const file = ledgerPath(root) @@ -600,7 +686,14 @@ function writeLedgerFile( return { ok: false, reason: `refusing ledger write: could not shrink skills allow-list first (a stale entry may still enable a disabled skill): ${error instanceof Error ? error.message : String(error)}` } } } - writeFileAtomicSync(file, JSON.stringify({ v: 2, receipts, records }, null, 2) + "\n") // 账本 durable + // 信封版本 = V3 段是否非空。没装过 package 的账本继续写 v:2,老构建照常可读可写; + // 一旦有图/claim 就写 v:3,而只懂 V2 的构建对 v:3 早已 fail-closed(downgrade 不会 + // 悄悄把 claims 抹掉,它压根不敢动这个文件)。 + const active = isV3Active(v3.packageGraphs, v3.claims) + const payload = active + ? { v: PACKAGE_LEDGER_ENVELOPE_VERSION, receipts, records, packageGraphs: v3.packageGraphs, claims: v3.claims } + : { v: 2, receipts, records } + writeFileAtomicSync(file, JSON.stringify(payload, null, 2) + "\n") // 账本 durable // 完整 next 发布(新增在账本之后):absent 首建、pre 后补新增、纯扩容都需要;已相等则跳过。 const alreadyFinal = preKeys === null && Array.isArray(cur) && cur.length === nextKeys.length && cur.every((k) => nextKeys.includes(k)) if (!alreadyFinal) { @@ -783,7 +876,13 @@ export function upsertRecordV2(root: string, input: UpsertInput, publishFinal?: if (!check.ok) return { ok: false, reason: `refusing to write invalid record: ${check.errors.join("; ")}` } const nextRecords = [...parsed.records.filter((r) => key(r.kind, r.name) !== k), check.record, ...parsed.rawCorruptRecords] const nextReceipts = [...parsed.receipts.filter((r) => key(r.type, r.name) !== k), toV1Receipt(check.record), ...parsed.rawInvalidReceipts] - const written = writeLedgerFile(root, nextReceipts, nextRecords, publishFinal) + const written = writeLedgerFile( + root, + nextReceipts, + nextRecords, + { packageGraphs: parsed.packageGraphs, claims: ensureStandaloneClaims(parsed.claims, [check.record]) }, + publishFinal, + ) if (!written.ok) return written return { ok: true, record: check.record, warnings, ...(written.projectionLag ? { projectionLag: written.projectionLag } : {}) } } @@ -857,11 +956,33 @@ export function upsertRecordsV2(root: string, inputs: UpsertInput[], publishFina ...[...committedKeys].map((k) => toV1Receipt(recordsByKey.get(k)!)), ...parsed.rawInvalidReceipts, ] - const written = writeLedgerFile(root, finalReceipts, finalRecords, publishFinal) + const written = writeLedgerFile( + root, + finalReceipts, + finalRecords, + { packageGraphs: parsed.packageGraphs, claims: ensureStandaloneClaims(parsed.claims, committed) }, + publishFinal, + ) if (!written.ok) return written return { ok: true, records: committed, warnings, ...(written.projectionLag ? { projectionLag: written.projectionLag } : {}) } } +/** + * REQ-128 `#706`:standalone 安装写 standalone claim —— 但**只在 V3 已激活时**。 + * + * 为什么带这个条件:claim 存在的意义是「这东西还有别人在用吗」。一个从没装过 package 的 + * 账本里不可能有共享,给每条 record 都造一个 claim 只会把所有人的信封提前推成 v:3、 + * 换不来任何保护。V3 一旦激活(第一次 package 安装),`applyPackageMutation` 会把当时 + * 已在册、没有 claim 的存量一律标成 `legacy-protected`(不猜历史),此后新装的 standalone + * 才带上自己的 owner —— 从那一刻起 owner 集合是完备的。 + */ +function ensureStandaloneClaims(claims: readonly PackageClaimV1[], records: readonly InstallRecordV2[]): PackageClaimV1[] { + if (claims.length === 0) return [...claims] + let next = [...claims] + for (const rec of records) next = withOwner(next, rec.kind, rec.name, standaloneOwner(rec.kind, rec.name)) + return next +} + /** Remove by (kind, name) from BOTH views. Missing = ok(idempotent), removed record returned for teardown对账。 */ /** #346(review #374 Major):卸载 journal key(`--`)的严格解析 —— 未知 kind / * 无分隔 / 空名一律 null。恢复期的账本删除对 null **必须抛错**(journal 保持非终态待诊断), @@ -888,18 +1009,77 @@ export function removeRecordV2(root: string, kind: InstallReceiptType, name: str return { ok: false, reason: `refusing to remove ${k}: ledger holds a corrupt v2 record for this key (fail closed — inspect ${ledgerPath(root)})` } if (parsed.corruptRecords.unattributable) return { ok: false, reason: `refusing to remove ${k}: ledger holds an unattributable corrupt v2 record (fail closed — inspect ${ledgerPath(root)})` } + // REQ-128 `#706`:仍被某个 Bundle 拥有的 child 不能去账 —— 去了账,claim 就指向一个不存在的 + // record(dangling),而那个 Bundle 从此无法正确卸载。调用方必须先问 `planDirectUninstall`, + // 它会在**删任何实物之前**给出「只释放 claim」的判决;走到这里还被拒 = 调用方漏问了。 + const claim = findClaim(parsed.claims, kind, name) + const verdict = directUninstallVerdict(claim, kind, name) + if (verdict.decision === "release-claim-only") + return { + ok: false, + reason: `refusing to remove ${k}: still owned by ${verdict.remainingOwners.join(", ")} — release the standalone claim instead of dropping the record`, + } const removed = parsed.records.find((r) => key(r.kind, r.name) === k) ?? null const hadReceipt = parsed.receipts.some((r) => key(r.type, r.name) === k) - if (!removed && !hadReceipt && !corrupt) return { ok: true, removed: null } + if (!removed && !hadReceipt && !corrupt && !claim) return { ok: true, removed: null } const written = writeLedgerFile( root, [...parsed.receipts.filter((r) => key(r.type, r.name) !== k), ...parsed.rawInvalidReceipts], [...parsed.records.filter((r) => key(r.kind, r.name) !== k), ...parsed.rawCorruptRecords], + // record 没了,claim 整条跟着走(含 legacy-protected:用户显式卸载是他自己的决定, + // legacy 保护挡的是**自动回收**,不是用户)。留着就是 dangling。 + { packageGraphs: parsed.packageGraphs, claims: withoutClaim(parsed.claims, kind, name) }, ) if (!written.ok) return written return { ok: true, removed } } +export type DirectUninstallPlan = + | { ok: true; decision: "delete" } + | { ok: true; decision: "release-claim-only"; remainingOwners: string[] } + | { ok: false; reason: string } + +/** + * REQ-128 `#706`(R2 Blocker 的直接修法):claim-aware 判决**前移到删实物之前**。 + * + * 卸载编排今天的形状是「先删实物、再去账」。V3 的 repository 会在仍有 Bundle owner 时拒写, + * 而那时实物已经没了 —— 用户看到「卸载失败」但东西真没了。所以决定必须在这里先做出来: + * 还有 Bundle 要用 ⇒ **一件实物都不动**,只把 standalone claim 释放掉。 + */ +export function planDirectUninstall(root: string, kind: InstallReceiptType, name: string): DirectUninstallPlan { + const { parsed, corrupt, readError } = parseLedger(root) + if (readError) return { ok: false, reason: `${readError} — refusing to uninstall` } + if (corrupt) return { ok: false, reason: `installs.json unreadable: ${ledgerPath(root)} — refusing to uninstall` } + const verdict = directUninstallVerdict(findClaim(parsed.claims, kind, name), kind, name) + return verdict.decision === "delete" ? { ok: true, decision: "delete" } : { ok: true, decision: "release-claim-only", remainingOwners: verdict.remainingOwners } +} + +/** 只释放这个 child 的 standalone claim,实物与 record 原样保留(仍有 Bundle 在用)。 */ +export function releaseStandaloneClaim(root: string, kind: InstallReceiptType, name: string): { ok: true; remainingOwners: string[] } | { ok: false; reason: string } { + const { parsed, corrupt, readError } = parseLedger(root) + if (readError) return { ok: false, reason: `${readError} — refusing to write` } + if (corrupt) return { ok: false, reason: `installs.json unreadable: ${ledgerPath(root)} — refusing to write` } + const claims = withoutOwner(parsed.claims, kind, name, standaloneOwner(kind, name)) + const written = writeLedgerFile( + root, + [...parsed.receipts, ...parsed.rawInvalidReceipts], + [...parsed.records, ...parsed.rawCorruptRecords], + { packageGraphs: parsed.packageGraphs, claims }, + ) + if (!written.ok) return written + return { ok: true, remainingOwners: findClaim(claims, kind, name)?.owners ?? [] } +} + +/** 只读:某个 child 当前的 owner 集合(空 = 无 claim)。 */ +export function packageClaimOwners(root: string, kind: string, name: string): string[] { + return findClaim(parseLedger(root).parsed.claims, kind, name)?.owners ?? [] +} + +/** 只读:账本里的 package 图(渲染/诊断面;`#698` 的 diff 消费同一真源)。 */ +export function readPackageGraphs(root: string): PackageGraphV1[] { + return parseLedger(root).parsed.packageGraphs +} + /** desiredState 翻转(Hub 项目上下文「禁用」的 main 侧真源;引擎生效面由消费方处理)。 * #336:ok 臂携带 projectionLag(skill enable 后派生允许集发布失败 = 账本已 durable、注入待 * boot 自愈)—— 用户可见开关入口必须呈现。 */ @@ -922,10 +1102,12 @@ export function setDesiredStateV2( const rec = parsed.records.find((r) => key(r.kind, r.name) === k) if (!rec) return { ok: false, reason: `no v2 record for ${k} — fail closed (v1-only installs have no desired-state channel)` } const next: InstallRecordV2 = { ...rec, desiredState: state, updatedAt: new Date().toISOString() } + // Bundle 只拥有**安装** claim,不拥有用户的启停 —— desiredState 翻转对 V3 段是纯透传。 const written = writeLedgerFile( root, [...parsed.receipts, ...parsed.rawInvalidReceipts], [...parsed.records.filter((r) => key(r.kind, r.name) !== k), next, ...parsed.rawCorruptRecords], + { packageGraphs: parsed.packageGraphs, claims: parsed.claims }, publishFinal, ) return written.ok ? { ok: true, ...(written.projectionLag ? { projectionLag: written.projectionLag } : {}) } : written @@ -998,9 +1180,9 @@ export function migrateV1Ledger( try { const raw = JSON.parse(fs.readFileSync(ledgerPath(root), "utf8")) as Record for (const k of Object.keys(raw)) - if (k !== "v" && k !== "receipts" && k !== "records") + if (k !== "v" && k !== "receipts" && k !== "records" && k !== "packageGraphs" && k !== "claims") return { ok: false, reason: `refusing migration: unknown ledger envelope key "${k}" — not this build's ledger shape (file left untouched)` } - if (raw.v !== 1 && raw.v !== 2) + if (raw.v !== 1 && raw.v !== 2 && raw.v !== PACKAGE_LEDGER_ENVELOPE_VERSION) return { ok: false, reason: `refusing migration: unsupported ledger version ${JSON.stringify(raw.v)} (file left untouched)` } if (raw.receipts !== undefined && !Array.isArray(raw.receipts)) return { ok: false, reason: "refusing migration: ledger receipts is not an array (file left untouched)" } @@ -1048,11 +1230,124 @@ export function migrateV1Ledger( } } if (adopted.length === 0) return { ok: true, migrated: 0, retained, warnings } - const written = writeLedgerFile(root, [...parsed.receipts, ...parsed.rawInvalidReceipts], [...parsed.records, ...adopted, ...parsed.rawCorruptRecords]) + // REQ-128 `#706`(基线 §2.9):v1 存量收编进 V3 一律 `legacy-protected` —— **不猜**它是不是 + // 某个历史 Bundle 的一部分。legacy 保护挡的是自动回收,不挡用户自己的显式卸载。 + const written = writeLedgerFile( + root, + [...parsed.receipts, ...parsed.rawInvalidReceipts], + [...parsed.records, ...adopted, ...parsed.rawCorruptRecords], + { packageGraphs: parsed.packageGraphs, claims: legacyProtectAll(parsed.claims, adopted) }, + ) if (!written.ok) return written return { ok: true, migrated: adopted.length, retained, warnings } } +/** 给还没有 claim 的 record 打上 `legacy-protected`。V3 未激活(claims 为空)时是 no-op —— + * 没有共享就没有需要保护的对象,凭空造 claim 只会提前把信封推成 v:3。 */ +function legacyProtectAll(claims: readonly PackageClaimV1[], records: readonly InstallRecordV2[]): PackageClaimV1[] { + if (claims.length === 0) return [...claims] + let next = [...claims] + for (const rec of records) if (!findClaim(next, rec.kind, rec.name)) next = withOwner(next, rec.kind, rec.name, LEGACY_PROTECTED_OWNER) + return next +} + +// ── V3 repository:唯一的 package mutation 提交面 ──────────────────────────────────────────────── + +export type PackageMutationWrite = + | { ok: true; replayed: boolean; warnings: string[] } + | { ok: false; reason: string } + +/** + * REQ-128 `#706`:把一个 `PackageLedgerMutationV1` 一次校验、一次 rename 落盘。 + * + * 「一次」是这里唯一重要的词。child record、package 图与 claim 集必须**同生同死** —— + * 分两次写就会存在「child 已 durable 但没人认领」的窗口,而在那个窗口里崩溃,owner 集合 + * 永远无法自证(不知道那个 child 是谁装的,于是既不敢删也不敢共享)。 + * + * exact replay(基线 §2.9):同一个 transactionId 重放时,若 package 记录与图逐字相同就 + * 直接返回 —— 崩溃恢复会重放 `commitReceipt`,重复施加 claim mutation 会把 acquire 做两次 + * (集合幂等,无害)但 release 做两次可能把别人的 owner 也带走。所以判等在先。 + */ +export function applyPackageMutation(root: string, mutation: PackageLedgerMutationV1): PackageMutationWrite { + const { parsed, corrupt, readError } = parseLedger(root) + if (readError) return { ok: false, reason: `${readError} — refusing to write` } + if (corrupt) return { ok: false, reason: `installs.json unreadable: ${ledgerPath(root)} — refusing to write a package mutation` } + const warnings: string[] = [...parsed.recordWarnings, ...parsed.receiptWarnings] + if (parsed.corruptRecords.unattributable) + return { ok: false, reason: `refusing package mutation: ledger holds an unattributable corrupt v2 record (fail closed — inspect ${ledgerPath(root)})` } + + const existingGraph = mutation.packageRecord + ? (parsed.packageGraphs.find((g) => g.packageId === mutation.packageRecord!.packageId) ?? null) + : (mutation.graphBeforeDigest ? (parsed.packageGraphs.find((g) => g.graphDigest === mutation.graphBeforeDigest) ?? null) : null) + // graphBefore 对不上 = 有人在我们计划之后改过账本(或这是一次陈旧重放)。拒。 + const beforeDigest = existingGraph?.graphDigest ?? null + if ((mutation.graphBeforeDigest ?? null) !== beforeDigest && !(existingGraph && mutation.graphAfter && existingGraph.graphDigest === mutation.graphAfter.graphDigest)) + return { + ok: false, + reason: `refusing package mutation: ledger graph digest ${beforeDigest ?? ""} does not match the expected before-image ${mutation.graphBeforeDigest ?? ""}`, + } + // exact replay:图已经是目标态 ⇒ 这次提交此前已 durable,原样返回(绝不重放 claim mutation)。 + if (existingGraph && mutation.graphAfter && existingGraph.graphDigest === mutation.graphAfter.graphDigest) + return { ok: true, replayed: true, warnings } + + // child record:先按批量 upsert 的同一语义在内存里算好,再与图/claim 一起一次写盘。 + const recordsByKey = new Map(parsed.records.map((r) => [key(r.kind, r.name), r])) + const receiptKeys = new Set(parsed.receipts.map((r) => key(r.type, r.name))) + const touched = new Set() + const removedKeys = new Set() + for (const child of mutation.childRecordMutations) { + if (child.op === "remove") { + removedKeys.add(key(child.kind, child.name)) + recordsByKey.delete(key(child.kind, child.name)) + continue + } + const k = key(child.input.kind, child.input.name) + if (parsed.corruptRecords.corruptKeys.has(k)) + return { ok: false, reason: `refusing package mutation: ledger holds a corrupt v2 record for ${k} (fail closed — inspect ${ledgerPath(root)})` } + const prev = recordsByKey.get(k) ?? null + const { sessionGrantEnforced, ...inputBare } = child.input + const record: InstallRecordV2 = { + ...inputBare, + schemaVersion: RECORD_SCHEMA_VERSION, + generation: child.input.generation ?? (prev ? prev.generation + 1 : receiptKeys.has(k) ? 2 : 1), + ...(child.input.previousDigest ? { previousDigest: child.input.previousDigest } : prev?.manifestDigest ? { previousDigest: prev.manifestDigest } : {}), + desiredState: sessionGrantEnforced === true ? "disabled" : prev ? prev.desiredState : child.input.desiredState, + } + const check = decodeRecordV2(record) + if (!check.ok) return { ok: false, reason: `refusing package mutation: invalid child record ${k}: ${check.errors.join("; ")}` } + recordsByKey.set(k, check.record) + touched.add(k) + } + + // 存量收编:V3 第一次落地时,账本里已有的、没人认领的 record 一律 `legacy-protected`。 + let claims: PackageClaimV1[] = [...parsed.claims] + for (const rec of recordsByKey.values()) + if (!touched.has(key(rec.kind, rec.name)) && !findClaim(claims, rec.kind, rec.name)) claims = withOwner(claims, rec.kind, rec.name, LEGACY_PROTECTED_OWNER) + for (const cm of mutation.claimMutations) + claims = cm.op === "acquire" ? withOwner(claims, cm.kind, cm.name, cm.owner) : withoutOwner(claims, cm.kind, cm.name, cm.owner) + for (const k of removedKeys) { + const [kind, ...rest] = k.split(":") + claims = withoutClaim(claims, kind!, rest.join(":")) + } + + const nextGraphs = mutation.packageRecord + ? [...parsed.packageGraphs.filter((g) => g.packageId !== mutation.packageRecord!.packageId), mutation.graphAfter!] + : parsed.packageGraphs.filter((g) => g.graphDigest !== mutation.graphBeforeDigest) + + const nextRecords = [...recordsByKey.values(), ...parsed.rawCorruptRecords] + const nextReceipts = [ + ...parsed.receipts.filter((r) => !touched.has(key(r.type, r.name)) && !removedKeys.has(key(r.type, r.name))), + ...[...touched].map((k) => toV1Receipt(recordsByKey.get(k)!)), + ...parsed.rawInvalidReceipts, + ] + const written = writeLedgerFile(root, nextReceipts, nextRecords, { packageGraphs: nextGraphs, claims }) + if (!written.ok) return written + return { ok: true, replayed: false, warnings } +} + +/** package 图对应的 owner token(claim mutation 与 graph 用同一个派生口径,不许各算各的)。 */ +export const packageOwnerToken = (graph: PackageGraphV1): string => bundleOwner(graph.packageId, graph.root.manifestDigest) + // ── grant digest ──────────────────────────────────────────────────────────────────────────────── /** grant 键集 digest:secret 变量名 + env 键 + workspace/cnMirror 布尔 —— 绝不摄入任何值。 */ diff --git a/packages/ui-mac/src/main/ext-transaction.ts b/packages/ui-mac/src/main/ext-transaction.ts index ca8415263e85..afc6f8637cdb 100644 --- a/packages/ui-mac/src/main/ext-transaction.ts +++ b/packages/ui-mac/src/main/ext-transaction.ts @@ -118,6 +118,11 @@ export type TxPlanItem = { /** receipt 模板(不透明透传:本层不解释)。持久化进 journal + commit record,使崩溃恢复能自足 * 前滚提交 receipt(REQ-100 #312:recovery 用同一 probe 判健康后落账,而非 health-by-assumption)。 */ receipt?: unknown + /** REQ-128 `#706`:package 账本 mutation 的静态半场(不透明透传,本层不解释)。**只挂在 root + * package item 上** —— child item 各自持 capabilities/probe,但不独立驱动落账;否则会出现 + * 「child records 已 durable、graph/claims 缺失」这种谁也无法自证的半态(基线 §2.9)。 + * 与 receipt 同样持久化进 journal + commit record,主提交与崩溃前滚据此算出同一份 mutation。 */ + packageMutation?: unknown } /** 判别式取值(缺省 generation)。 */ @@ -246,6 +251,8 @@ export type TxCommitRecord = { fileTarget?: string /** receipt 模板(不透明透传;commitReceipt 消费方据此落账,恢复前滚同源)。 */ receipt?: unknown + /** REQ-128 `#706`:package mutation 静态半场(不透明透传;落账方据此提交唯一 root mutation)。 */ + packageMutation?: unknown committedAt: string } @@ -365,6 +372,8 @@ export type TxJournalItem = { file?: { relTarget: string; slot: number; preDigest: string; nextDigest: string; preAbsent: boolean; requireAbsent: boolean; applied?: boolean } /** receipt 模板(不透明透传;恢复前滚据此重建 InstallRecordV2,无需 caller 上下文)。 */ receipt?: unknown + /** REQ-128 `#706`:package mutation 静态半场(不透明透传;恢复前滚据此重建同一份 mutation)。 */ + packageMutation?: unknown manifestDigest?: string /** committed 后写授权账用(恢复前滚也要写,故持久化在 journal 里)。 */ capabilities?: string[] @@ -464,7 +473,10 @@ function removeReceiptSnapshot(root: string, key: string, genId: string): void { /** 从 journal item 构造 commit record(主提交与恢复前滚同源;透传 receipt 模板 REQ-100 #312)。 */ function buildCommitRecord(root: string, txId: string, it: TxJournalItem, committedAt: string): TxCommitRecord { - const receipt = it.receipt !== undefined ? { receipt: it.receipt } : {} + const receipt = { + ...(it.receipt !== undefined ? { receipt: it.receipt } : {}), + ...(it.packageMutation !== undefined ? { packageMutation: it.packageMutation } : {}), + } const kind = actionOf(it) if (kind === "generation") return { @@ -637,6 +649,11 @@ function validatePlan(root: string, plan: TxPlan): string | null { if (plan.txId !== undefined && !TX_ID_RE.test(plan.txId)) return `invalid txId: ${plan.txId}` if (plan.items.length === 0) return "plan has no items" if (plan.items.length > 64) return "plan exceeds 64 items" + // REQ-128 `#706`(基线 §2.9):**只有 root package item 携带 mutation**。两个就意味着一次 + // 事务里有两个 package 真相,提交顺序会决定谁赢 —— 在写盘之前拒,零副作用。 + const mutationCarriers = plan.items.filter((it) => it && typeof it === "object" && it.packageMutation !== undefined) + if (mutationCarriers.length > 1) + return `plan carries ${mutationCarriers.length} package ledger mutations (${mutationCarriers.map((it) => it.key).join(", ")}) — only the root package item may carry one` const keys = new Set() // #378 r13 Major:同一物理 config 文件的**别名 target**(/a/alpha.jsonc 与 /a/sub/../alpha.jsonc) // 在 prepare 期按原始字符串各自成链(第二条覆盖第一条的写),恢复期按 resolve 归一又当同一条 @@ -1191,6 +1208,7 @@ export async function runExtensionTransaction(root: string, plan: TxPlan, hooks: : {}), manifestDigest: item.manifestDigest, ...(item.receipt !== undefined ? { receipt: item.receipt } : {}), + ...(item.packageMutation !== undefined ? { packageMutation: item.packageMutation } : {}), capabilities: item.capabilities, })), authorization: { diff --git a/packages/ui-mac/src/main/package-admission.parity.test.ts b/packages/ui-mac/src/main/package-admission.parity.test.ts index 8985cc9c6ac1..f55a5c4439fe 100644 --- a/packages/ui-mac/src/main/package-admission.parity.test.ts +++ b/packages/ui-mac/src/main/package-admission.parity.test.ts @@ -17,6 +17,7 @@ import { join } from "node:path" import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { agentFileProbe } from "./ext-agent-install" import { extensionHealthProbeRouter, seedPluginFileProbe } from "./ext-health-probe-router" +import { computeGraphDigest, type PackageGraphV1 } from "./ext-package-ledger-v3" import { skillGenerationProbe } from "./ext-skill-generations" import { runExtensionTransaction, type HealthProbe, type TxPlanItem } from "./ext-transaction" import { createPackageAdmissionCoordinator } from "./package-admission" @@ -185,8 +186,42 @@ describe("REQ-128 #705 single-install builder parity", () => { origin: "catalog", installedAt: "2026-07-31T00:00:00.000Z", }, + // REQ-128 `#706`:root item 多带一份 V3 mutation 静态半场。这是 `#705` parity 之后 + // **唯一**的计划面增量,所以在这里逐字钉死 —— 形状漂了就红。 + packageMutation: { + operation: "install", + graphBeforeDigest: null, + packageRecord: { + packageId: "package:parity-skill", + envelopeDigest: (plan.items[0]!.packageMutation as { packageRecord: { envelopeDigest: string } }).packageRecord.envelopeDigest, + graphDigest: (plan.items[0]!.packageMutation as { packageRecord: { graphDigest: string } }).packageRecord.graphDigest, + version: "1.0.0", + transactionId: "tx-assigned-at-commit", + installedAt: "2026-07-31T00:00:00.000Z", + }, + graphAfter: { + packageId: "package:parity-skill", + envelopeDigest: (plan.items[0]!.packageMutation as { graphAfter: { envelopeDigest: string } }).graphAfter.envelopeDigest, + graphDigest: (plan.items[0]!.packageMutation as { graphAfter: { graphDigest: string } }).graphAfter.graphDigest, + root: { + componentId: "skill:demo", + kind: "skill", + name: "demo", + required: true, + manifestDigest: plan.items[0]!.manifestDigest, + }, + children: [], + }, + claimMutations: [ + { op: "acquire", kind: "skill", name: "demo", owner: `bundle:package:parity-skill@${plan.items[0]!.manifestDigest}` }, + ], + }, }, ]) + // 图/记录的 digest 不是自由字符串:重算一遍必须逐字相同(篡改任一节点 → 解码期就红)。 + const mutation = plan.items[0]!.packageMutation as { graphAfter: PackageGraphV1; packageRecord: { graphDigest: string } } + expect(computeGraphDigest(mutation.graphAfter)).toBe(mutation.graphAfter.graphDigest) + expect(mutation.packageRecord.graphDigest).toBe(mutation.graphAfter.graphDigest) expect(plan.authorization).toEqual({ confirmed: { "skill--demo": [] }, decidedAt: "2026-07-31T00:00:00.000Z" }) const staging = join(tmp, "staging") mkdirSync(staging, { recursive: true }) diff --git a/packages/ui-mac/src/main/package-admission.ts b/packages/ui-mac/src/main/package-admission.ts index 9204afe694ca..2298193dc2d0 100644 --- a/packages/ui-mac/src/main/package-admission.ts +++ b/packages/ui-mac/src/main/package-admission.ts @@ -9,7 +9,14 @@ import { agentInstallKey, recoveryReceiptInputs } from "./ext-agent-install" import { nextDesiredState } from "./ext-install-policy" import { canonicalJson, sha256Hex } from "./ext-manifest-v2" import { buildAgentTxItems, buildMcpTxItems, buildSkillTxItems } from "./ext-package-tx-builders" -import { computeGrantDigest, upsertRecordsV2, type UpsertInput } from "./ext-receipt-v2" +import { computeGrantDigest, readPackageGraphs, type UpsertInput } from "./ext-receipt-v2" +import { commitTransactionLedger } from "./ext-package-ledger-commit" +import { + bundleOwner, + computeGraphDigest, + type PackageGraphV1, + type PackageMutationEnvelopeV1, +} from "./ext-package-ledger-v3" import { skillGenerationKey } from "./ext-skill-generations" import { runExtensionTransaction, @@ -398,12 +405,20 @@ async function executePreparedPackage( }) if (!built.ok) return { ok: false, reason: `package admission: ${built.reason}` } const build = built.build + // REQ-128 `#706`:V3 mutation 只挂在 root package item 上(本期单组件 ⇒ root = `prepared.key`)。 + // 挂错 item 或挂多份都会被 `validatePlan` 在写盘前拒掉。 + const rootItemIndex = build.items.findIndex((item) => item.key === prepared.key) + if (rootItemIndex < 0) + return { ok: false, reason: `package admission: planning builder produced no root item for "${prepared.key}" — refusing (no ledger mutation carrier)` } + const items = build.items.map((item, index) => + index === rootItemIndex ? { ...item, packageMutation: packageMutationEnvelope(root, prepared, manifestDigest, now) } : item, + ) // #712:受限密钥版本以**类型化 descriptor** 进计划(→ journal),不再只是一对匿名闭包。 // 释放归引擎调度(abort/rollback 前),恢复期对同一条 journal 做同一件事 —— 单一真源。 const result = await (deps.transaction ?? runExtensionTransaction)( root, - packagePlan(build.items, intent, now, build.prepared?.descriptor), + packagePlan(items, intent, now, build.prepared?.descriptor), { populate: build.populate, ...(build.prepared @@ -437,9 +452,52 @@ function packagePlan( } } +/** + * REQ-128 `#706`:package 安装的**有效安装图**。本期合同只允许单组件,所以 root 就是唯一 + * 组件、`children` 为空;`#697` 放开多组件后本函数换成逐组件构造,图文法与 digest 口径不变。 + */ +function packageGraphOf(prepared: PreparedPackage, manifestDigest: string): PackageGraphV1 { + const component = prepared.facts.envelope.components[0]! + const withoutDigest = { + packageId: prepared.facts.envelope.prelude.packageId, + envelopeDigest: `sha256:${prepared.binding.envelopeDigest}`, + root: { + componentId: component.id, + kind: prepared.kind, + name: prepared.name, + required: true, + manifestDigest, + }, + children: [], + } + return { ...withoutDigest, graphDigest: computeGraphDigest(withoutDigest) } +} + +/** 挂在 root package item 上的静态半场:图、package 记录与 claim mutation。 + * child record mutation 在提交时由 commit records 派生(`commitTransactionLedger`), + * 所以主提交与崩溃前滚算出的是同一份 mutation。 */ +function packageMutationEnvelope(root: string, prepared: PreparedPackage, manifestDigest: string, now: string): PackageMutationEnvelopeV1 { + const graph = packageGraphOf(prepared, manifestDigest) + const before = readPackageGraphs(root).find((g) => g.packageId === graph.packageId) ?? null + return { + operation: before ? "update" : "install", + packageRecord: { + packageId: graph.packageId, + envelopeDigest: graph.envelopeDigest, + graphDigest: graph.graphDigest, + ...(prepared.facts.envelope.prelude.version ? { version: prepared.facts.envelope.prelude.version } : {}), + // 占位:计划期还没有 txId(事务自产)。`commitTransactionLedger` 在提交点统一覆盖成真值。 + transactionId: "tx-assigned-at-commit", + installedAt: now, + }, + graphBeforeDigest: before?.graphDigest ?? null, + graphAfter: graph, + claimMutations: [{ op: "acquire", kind: prepared.kind, name: prepared.name, owner: bundleOwner(graph.packageId, manifestDigest) }], + } +} + function commitPackageReceipts(root: string, records: TxCommitRecord[]) { - const written = upsertRecordsV2(root, recoveryReceiptInputs(records)) - if (!written.ok) throw new Error(`package receipt commit failed: ${written.reason}`) + commitTransactionLedger(root, records) } function transactionOutcome( From a6aa96f86fd98e6a78833e651a9f0fb85a3d40d2 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 00:08:58 -0400 Subject: [PATCH 2/9] =?UTF-8?q?test:=20#706=20production=20wiring=20?= =?UTF-8?q?=E2=80=94=20real=20IPC=20package=20install=20must=20land=20V3?= =?UTF-8?q?=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../package-admission.wiring.cases.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/ui-mac/test-component/package-admission.wiring.cases.ts b/packages/ui-mac/test-component/package-admission.wiring.cases.ts index 650650791562..b97438131af6 100644 --- a/packages/ui-mac/test-component/package-admission.wiring.cases.ts +++ b/packages/ui-mac/test-component/package-admission.wiring.cases.ts @@ -8,6 +8,7 @@ import type { PackageProfilePayloadV1, } from "../src/shared/host-extension-package-contract/decoder" import type { PackageAdmissionPreviewV1 } from "../src/shared/package-admission" +import { computeGraphDigest, type PackageGraphV1 } from "../src/main/ext-package-ledger-v3" type IpcHandler = (event: { sender: { id: number } }, ...args: unknown[]) => unknown @@ -243,6 +244,42 @@ test("real ext-install-catalog IPC binds preview, revalidates, then commits thro expect(config).not.toContain(secretCanary) expect(existsSync(join(root, "installs.json"))).toBe(true) expect(existsSync(join(root, "ext-store", "mcp--generic-remote", "grants.json"))).toBe(true) + + // REQ-128 `#706`:**生产接线** —— 真 IPC + 真事务提交后,账本必须是 V3 信封,且带着这个 + // package 的图与 claim。没有这一段,把 `package-admission` 里挂 `packageMutation` 的那行删掉 + // 之后一切照绿(`commitTransactionLedger` 会静默走回 V2 的 upsert 分支)—— 那正是「闸门没测 + // 生产接线」的形状。判据:删掉那行生产调用,下面五条一起红。 + const ledger = JSON.parse(readFileSync(join(root, "installs.json"), "utf8")) as { + v: number + records: Array<{ kind: string; name: string; manifestDigest: string }> + packageGraphs: PackageGraphV1[] + claims: Array<{ kind: string; name: string; owners: string[] }> + } + const childRecord = ledger.records.find((r) => r.kind === "mcp" && r.name === "generic-remote") + if (!childRecord) throw new Error("expected an InstallRecordV2 for mcp:generic-remote") + const itemDigest = `sha256:${preview.packageAuthorization.binding.itemDigests["mcp:generic-remote"]}` + expect(ledger.v).toBe(3) + expect(ledger.packageGraphs).toEqual([ + { + packageId: envelope.prelude.packageId, + envelopeDigest: `sha256:${preview.packageAuthorization.binding.envelopeDigest}`, + graphDigest: ledger.packageGraphs[0]!.graphDigest, + root: { + componentId: envelope.components[0].id, + kind: "mcp", + name: "generic-remote", + required: true, + manifestDigest: itemDigest, + }, + children: [], + }, + ]) + // graphDigest 不是自由字符串:重算必须逐字相同(账本被改一个字节就解不开)。 + expect(computeGraphDigest(ledger.packageGraphs[0]!)).toBe(ledger.packageGraphs[0]!.graphDigest) + expect(childRecord.manifestDigest).toBe(itemDigest) + expect(ledger.claims).toEqual([ + { kind: "mcp", name: "generic-remote", owners: [`bundle:${envelope.prelude.packageId}@${itemDigest}`] }, + ]) const journals = readdirSync(join(root, "ext-tx", "journal")) expect(journals).toHaveLength(1) expect(JSON.parse(readFileSync(join(root, "ext-tx", "journal", journals[0]!), "utf8")).state).toBe("committed") From 2d313bed8b6be81dbe51235ba73621cbe8b7bc55 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 00:15:25 -0400 Subject: [PATCH 3/9] test+fix: #706 real-uninstall gates; probe and replay now judge V3 state --- .../main/ext-package-ledger-uninstall.test.ts | 561 ++++++++++++++++++ packages/ui-mac/src/main/ext-receipt-v2.ts | 36 +- 2 files changed, 590 insertions(+), 7 deletions(-) create mode 100644 packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts diff --git a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts new file mode 100644 index 000000000000..504d91ab7725 --- /dev/null +++ b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts @@ -0,0 +1,561 @@ +// REQ-128 `#706` —— V3 账本的**全写入口**闸:真实卸载路径、生产接线、篡改响亮失败。 +// +// 本文件与 `ext-package-ledger-v3.test.ts` 的分工:那边测纯代数与解码器,这边把真账本落在真盘上, +// 走**生产的** `uninstallByKey`,断言可观察的后果。三件事各自对应一个已被证伪过的失效形态: +// +// ① **repository 拒绝时实物一件都没动。** 卸载编排今天的形状是「先删实物、再去账」,而 V3 会在 +// 仍有 Bundle owner 时拒写 —— 判决晚一步,用户就会看到「卸载失败」而东西真的已经没了。 +// 这里的断言不是「文件还在」(那在很多路径下本来就成立),而是**先把不修就会被删的前置状态 +// 造出来**:假 installer 会真的 rm,plugin-path 分支里 planner 自己还有一次 `fs.rmSync`。 +// 把 `planDirectUninstall` 从 `ext-install-planner.ts` 拿掉,本组每一条都变红。 +// +// ② **每条路径只有一次 ledger mutation。** 用 `spyOn(fs.renameSync)` 数落到 installs.json 的 +// 原子换名 —— `writeFileAtomicSync` 每次提交恰好一次 rename,所以这是对物理提交次数的直接 +// 测量,不是对最终内容的推断(内容对幂等的重复写不敏感,数不出双写)。 +// +// ③ **篡改/悬空/冲突一律响亮失败且字节零改动。** 负向夹具里违规项**从不放在第一个**,并且集合里 +// 同时有合法项 —— 「检查每一个」写成「检查第一个」时必须红。 +// +// 账本夹具全部由**生产写器**造(upsert → applyPackageMutation → upsert),不手搓 JSON:手搓的 +// 夹具只能证明解码器读得懂我写的东西,证明不了生产路径写得出这个形状。 + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { addReceipt, readLedger } from "./alpha-installs" +import { + bundleOwner, + computeGraphDigest, + standaloneOwner, + LEGACY_PROTECTED_OWNER, + type PackageClaimV1, + type PackageGraphV1, + type PackageLedgerMutationV1, +} from "./ext-package-ledger-v3" +import { + applyPackageMutation, + findRecordV2, + lookupForUninstall, + migrateV1Ledger, + packageClaimOwners, + probeLedgerForWrite, + readLedgerV2, + readPackageGraphs, + releaseStandaloneClaim, + removeRecordV2, + setDesiredStateV2, + upsertRecordV2, + upsertRecordsV2, + type UpsertInput, +} from "./ext-receipt-v2" +import { + uninstallByKey, + type ConfigOutcome, + type FsOutcome, + type PlannerDeps, + type PlannerInstallers, + type TargetArg, +} from "./ext-install-planner" + +let tmp = "" +let root = "" + +const ledgerFile = (): string => path.join(root, "installs.json") +const readRaw = (): string => fs.readFileSync(ledgerFile(), "utf8") +const parsedLedger = (): { v: number; receipts: unknown[]; records: unknown[]; packageGraphs?: PackageGraphV1[]; claims?: PackageClaimV1[] } => + JSON.parse(readRaw()) as { v: number; receipts: unknown[]; records: unknown[]; packageGraphs?: PackageGraphV1[]; claims?: PackageClaimV1[] } + +// ── 夹具:一个装了 package 的真账本 ──────────────────────────────────────────────────────────── + +const PACKAGE_ID = "package:shared-kit" +const ROOT_DIGEST = `sha256:${"a".repeat(64)}` +const ENVELOPE_DIGEST = `sha256:${"b".repeat(64)}` +const OWNER = bundleOwner(PACKAGE_ID, ROOT_DIGEST) + +/** package 的 5 个组件 —— 每一种卸载路径各一个,好让「仍有 Bundle owner」这件事在每条路径上都可达。 */ +const SHARED = [ + { componentId: "skill:shared-skill", kind: "skill" as const, name: "shared-skill" }, + { componentId: "agent:shared-agent", kind: "agent" as const, name: "shared-agent" }, + { componentId: "mcp:shared-mcp", kind: "mcp" as const, name: "shared-mcp" }, + { componentId: "plugin:shared-plugin", kind: "plugin" as const, name: "shared-plugin" }, + { componentId: "plugin:shared-vendored", kind: "plugin" as const, name: "shared-vendored" }, +] + +/** 用户自己单装的同类物件 —— 卸载它们走的是「删」这一支,用来证明 V3 段不会在正常卸载里蒸发。 */ +const SOLO = [ + { kind: "skill" as const, name: "solo-skill" }, + { kind: "agent" as const, name: "solo-agent" }, + { kind: "mcp" as const, name: "solo-mcp" }, + { kind: "plugin" as const, name: "solo-plugin" }, + { kind: "plugin" as const, name: "solo-vendored" }, +] + +const configKeyFor = (kind: string, name: string): string | undefined => { + if (kind === "mcp") return `mcp.${name}` + if (kind !== "plugin") return undefined + return name.includes("vendored") ? `plugin-path:${path.join(root, "plugins", name, "plugin.js")}` : `plugin:${name}@1.0.0` +} + +const upsertInput = (kind: "skill" | "agent" | "mcp" | "plugin", name: string): UpsertInput => { + const configKey = configKeyFor(kind, name) + return { + id: `${kind}:${name}`, + name, + kind, + environment: "prod", + scope: { kind: "global" }, + version: "1.0.0", + manifestDigest: ROOT_DIGEST, + desiredState: "enabled", + origin: "catalog", + installedAt: "2026-07-31T00:00:00.000Z", + ...(configKey ? { configKey } : {}), + } +} + +const packageGraph = (): PackageGraphV1 => { + const [head, ...rest] = SHARED + const withoutDigest = { + packageId: PACKAGE_ID, + envelopeDigest: ENVELOPE_DIGEST, + root: { componentId: head!.componentId, kind: head!.kind, name: head!.name, required: true, manifestDigest: ROOT_DIGEST }, + children: rest.map((c) => ({ componentId: c.componentId, kind: c.kind, name: c.name, required: false, manifestDigest: ROOT_DIGEST })), + } + return { ...withoutDigest, graphDigest: computeGraphDigest(withoutDigest) } +} + +const packageMutation = (): PackageLedgerMutationV1 => ({ + transactionId: "tx-shared-kit-1", + operation: "install", + packageRecord: { + packageId: PACKAGE_ID, + envelopeDigest: ENVELOPE_DIGEST, + graphDigest: packageGraph().graphDigest, + version: "1.0.0", + transactionId: "tx-shared-kit-1", + installedAt: "2026-07-31T00:00:00.000Z", + }, + graphBeforeDigest: null, + graphAfter: packageGraph(), + childRecordMutations: SHARED.map((c) => ({ op: "upsert" as const, input: upsertInput(c.kind, c.name) })), + claimMutations: SHARED.map((c) => ({ op: "acquire" as const, kind: c.kind, name: c.name, owner: OWNER })), +}) + +/** 实物:skill 目录、agent md、vendored plugin 目录。假 installer 与 planner 自己都会真的删它们。 */ +function materialiseArtifacts(): void { + for (const { kind, name } of [...SHARED, ...SOLO]) { + if (kind === "skill") { + fs.mkdirSync(path.join(root, "skills", name), { recursive: true }) + fs.writeFileSync(path.join(root, "skills", name, "SKILL.md"), `---\nname: ${name}\n---\nbody`) + } + if (kind === "agent") { + fs.mkdirSync(path.join(root, "agents"), { recursive: true }) + fs.writeFileSync(path.join(root, "agents", `${name}.md`), "---\ndescription: d\n---\nsys") + } + if (kind === "plugin" && name.includes("vendored")) { + fs.mkdirSync(path.join(root, "plugins", name), { recursive: true }) + fs.writeFileSync(path.join(root, "plugins", name, "plugin.js"), "// vendored") + } + } +} + +const artifactPath = (kind: string, name: string): string => + kind === "skill" + ? path.join(root, "skills", name) + : kind === "agent" + ? path.join(root, "agents", `${name}.md`) + : path.join(root, "plugins", name) + +/** 生产写器造账本:①单装 5 个 shared child ②applyPackageMutation 激活 V3 ③V3 之后再单装 5 个 solo。 */ +function seedV3Ledger(): void { + const first = upsertRecordsV2(root, SHARED.map((c) => upsertInput(c.kind, c.name))) + if (!first.ok) throw new Error(`fixture: seeding shared children failed: ${first.reason}`) + const applied = applyPackageMutation(root, packageMutation()) + if (!applied.ok) throw new Error(`fixture: activating V3 failed: ${applied.reason}`) + const second = upsertRecordsV2(root, SOLO.map((c) => upsertInput(c.kind, c.name))) + if (!second.ok) throw new Error(`fixture: seeding solo children failed: ${second.reason}`) + materialiseArtifacts() +} + +// ── 假 installer:真删实物(这样「实物没动」才是有代价的断言)──────────────────────────────────── + +type Calls = string[] + +function makeDeps(calls: Calls): PlannerDeps { + const refuse = (fn: string) => (): never => { + throw new Error(`install-only installer ${fn} must not run on an uninstall path`) + } + const installers: PlannerInstallers = { + applyMcpWritePolicy: refuse("applyMcpWritePolicy"), + mcpSecretRefFor: refuse("mcpSecretRefFor"), + claimMcpSecretVersionDir: refuse("claimMcpSecretVersionDir"), + writeMcpSecretVersioned: refuse("writeMcpSecretVersioned"), + removeMcpSecretVersionDir: refuse("removeMcpSecretVersionDir"), + gcMcpSecrets: (name: string) => { + calls.push(`gcMcpSecrets:${name}`) + return { removed: [], warnings: [] } + }, + legacyMcpRefPaths: refuse("legacyMcpRefPaths"), + readMcpLeafStrict: refuse("readMcpLeafStrict"), + removeMcpConfigInLock: (name: string): ConfigOutcome => { + calls.push(`removeMcpConfigInLock:${name}`) + return { ok: true } + }, + removeMcpSecretsStrict: (name: string) => { + calls.push(`removeMcpSecretsStrict:${name}`) + return { ok: true } + }, + findPluginBaseConflictStrict: refuse("findPluginBaseConflictStrict"), + readPluginArrayStrict: refuse("readPluginArrayStrict"), + readLegacyPluginArrayStrict: refuse("readLegacyPluginArrayStrict"), + mcpConfigTruthPath: () => path.join(root, "alpha.jsonc"), + stageVendoredPluginVersioned: refuse("stageVendoredPluginVersioned"), + removePlugin: (pkg: string): ConfigOutcome => { + calls.push(`removePlugin:${pkg}`) + return { ok: true } + }, + collectVendoredPluginPayload: refuse("collectVendoredPluginPayload"), + removePluginPath: (name: string, absJsPath: string): ConfigOutcome => { + calls.push(`removePluginPath:${name}`) + fs.rmSync(absJsPath, { force: true }) // 生产同形:配置撤除 + 实物消失 + return { ok: true } + }, + installBuiltinSkill: refuse("installBuiltinSkill"), + collectBuiltinSkillPayload: refuse("collectBuiltinSkillPayload"), + collectBuiltinAgentPayload: refuse("collectBuiltinAgentPayload"), + installRemoteSkill: refuse("installRemoteSkill"), + removeFsInstall: (type: "skill" | "agent", name: string, _target?: TargetArg): FsOutcome => { + calls.push(`removeFsInstall:${type}:${name}`) + const target = artifactPath(type, name) + fs.rmSync(target, { recursive: true, force: true }) // 生产同形:实物真的没了 + return { ok: true, files: [target] } + }, + agentPresent: refuse("agentPresent"), + downloadRemoteAsset: refuse("downloadRemoteAsset"), + } + return { + advisoryGate: () => ({ allowed: true }), + resolveEntry: async () => null, + environment: () => "prod", + platform: () => "darwin", + globalRoot: () => root, + casBaseRoot: () => path.join(tmp, "cas-base"), + installers, + } +} + +// ── ledger 物理提交计数(writeFileAtomicSync = tmp→rename,一次提交恰好一次 rename)────────────── + +let renameSpy: ReturnType> | null = null + +function countLedgerWrites(): () => number { + const spy = spyOn(fs, "renameSync") + renameSpy = spy + const target = ledgerFile() + return () => spy.mock.calls.filter((args) => args[1] === target).length +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ext-v3-ledger-")) + root = path.join(tmp, "global") + fs.mkdirSync(root, { recursive: true }) +}) + +afterEach(() => { + renameSpy?.mockRestore() + renameSpy = null + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +// ── ① 真实卸载路径 ──────────────────────────────────────────────────────────────────────────── + +describe("REQ-128 #706 —— 真实卸载:仍有 Bundle owner ⇒ 实物一件都不动", () => { + // 判据不是「文件还在」。夹具里假 installer 与 planner 都会**真的删**,所以这四条只有在 + // claim-aware 判决位于删实物之前时才成立;把 `planDirectUninstall` 拿掉即全红。 + for (const { kind, name } of SHARED) { + test(`${kind}:${name} —— 只释放 standalone claim,零实物副作用,一次 mutation`, async () => { + seedV3Ledger() + const graphsBefore = readPackageGraphs(root) + const artifact = artifactPath(kind, name) + const artifactExisted = fs.existsSync(artifact) + // 用户此前也单装过同一个 child:standalone owner 与 Bundle owner 并存(共享的真实形态)。 + const claimed = upsertRecordsV2(root, [upsertInput(kind, name)]) + expect(claimed.ok).toBe(true) + expect(packageClaimOwners(root, kind, name).sort()).toEqual([OWNER, standaloneOwner(kind, name)].sort()) + + const calls: Calls = [] + const writes = countLedgerWrites() + const outcome = await uninstallByKey({ type: kind, name, scope: "global" }, makeDeps(calls)) + + expect(outcome).toMatchObject({ ok: true, retainedForOwners: [OWNER] }) + expect(calls).toEqual([]) // 没有任何 installer 被调用 = 一件实物都没动 + if (artifactExisted) expect(fs.existsSync(artifact)).toBe(true) + expect(writes()).toBe(1) // 只有「释放 claim」这一次提交 + // 账本:record 仍在(它还属于这个 package)、图逐字不变、claim 只少了用户那一份。 + expect(findRecordV2(root, kind, name)).not.toBeNull() + expect(readPackageGraphs(root)).toEqual(graphsBefore) + expect(packageClaimOwners(root, kind, name)).toEqual([OWNER]) + }) + } +}) + +describe("REQ-128 #706 —— 真实卸载:没有别的 owner ⇒ 正常删,且 V3 段不丢失", () => { + for (const { kind, name } of SOLO) { + test(`${kind}:${name} —— record/claim 一并消失,packageGraphs 与他人 claim 原样`, async () => { + seedV3Ledger() + const graphsBefore = readPackageGraphs(root) + const otherClaimsBefore = parsedLedger().claims?.filter((c) => !(c.kind === kind && c.name === name)) + expect(packageClaimOwners(root, kind, name)).toEqual([standaloneOwner(kind, name)]) + + const calls: Calls = [] + const writes = countLedgerWrites() + const outcome = await uninstallByKey({ type: kind, name, scope: "global" }, makeDeps(calls)) + + expect(outcome).toMatchObject({ ok: true }) + expect(calls.length).toBeGreaterThan(0) // 这一支必须真的走到 installer + expect(writes()).toBe(1) // 一次 mutation —— 内层 receipt 副作用回来就是 2 + expect(findRecordV2(root, kind, name)).toBeNull() + expect(packageClaimOwners(root, kind, name)).toEqual([]) + // V3 段活着:图逐字不变,其余 claim 逐字不变,信封仍是 v:3。 + expect(readPackageGraphs(root)).toEqual(graphsBefore) + expect(parsedLedger().claims).toEqual(otherClaimsBefore) + expect(parsedLedger().v).toBe(3) + }) + } +}) + +describe("REQ-128 #706 —— 只有一次 mutation:v:2 账本上的内层副作用探测器", () => { + // V3 未激活时,`alpha-installs` 的 v1 写器不会被 `refuseIfV3` 挡住 —— 于是「内层 removeReceipt + // 有没有被接回来」在这里是**可见的**:它会多出一次 rename。v:3 夹具上测不出这一条(v1 写器被 + // 拒在写之前),所以这个探测器必须留在 v:2 上。 + for (const { kind, name } of SOLO) { + test(`${kind}:${name} —— v:2 账本上卸载恰好提交一次`, async () => { + const seeded = upsertRecordsV2(root, SOLO.map((c) => upsertInput(c.kind, c.name))) + expect(seeded.ok).toBe(true) + materialiseArtifacts() + expect(parsedLedger().v).toBe(2) + + const calls: Calls = [] + const writes = countLedgerWrites() + const outcome = await uninstallByKey({ type: kind, name, scope: "global" }, makeDeps(calls)) + + expect(outcome).toMatchObject({ ok: true }) + expect(writes()).toBe(1) + expect(findRecordV2(root, kind, name)).toBeNull() + }) + } +}) + +// ── ② 第一次 V3 write 之后,扩展管理面全须仍然可用 ────────────────────────────────────────────── + +describe("REQ-128 #706 —— 第一次 V3 write 后全部扩展管理操作仍可用", () => { + test("读/单装/批装/启停/卸载/查询/迁移全绿,且信封保持 v:3", () => { + seedV3Ledger() + expect(parsedLedger().v).toBe(3) + + expect(probeLedgerForWrite(root).ok).toBe(true) + expect(readLedgerV2(root).records.length).toBe(SHARED.length + SOLO.length) + expect(readLedger(root).receipts.length).toBe(SHARED.length + SOLO.length) + expect(lookupForUninstall(root, "skill", "solo-skill").status).toBe("valid") + + // 单装一个全新的 child(V3 已激活 ⇒ 它自带 standalone claim) + const fresh = upsertRecordV2(root, upsertInput("skill", "fresh-skill")) + expect(fresh.ok).toBe(true) + expect(packageClaimOwners(root, "skill", "fresh-skill")).toEqual([standaloneOwner("skill", "fresh-skill")]) + + // 批装 + const batch = upsertRecordsV2(root, [upsertInput("agent", "fresh-agent"), upsertInput("mcp", "fresh-mcp")]) + expect(batch.ok).toBe(true) + + // 启停(对 V3 段是纯透传) + const graphsBefore = readPackageGraphs(root) + expect(setDesiredStateV2(root, "skill", "solo-skill", "disabled").ok).toBe(true) + expect(findRecordV2(root, "skill", "solo-skill")?.desiredState).toBe("disabled") + expect(readPackageGraphs(root)).toEqual(graphsBefore) + + // 去账 + expect(removeRecordV2(root, "skill", "fresh-skill").ok).toBe(true) + expect(findRecordV2(root, "skill", "fresh-skill")).toBeNull() + + // v1 → v2 迁移仍然跑得动(v:3 信封是它认得的形状) + expect(migrateV1Ledger(root, "prod").ok).toBe(true) + expect(parsedLedger().v).toBe(3) + expect(readPackageGraphs(root)).toEqual(graphsBefore) + }) + + test("第二个物理写器(v1 addReceipt/removeReceipt)在 V3 账本上响亮拒绝,字节零改动", () => { + seedV3Ledger() + const before = readRaw() + const written = addReceipt(root, { + id: "skill:v1-writer", + name: "v1-writer", + type: "skill", + scope: "global", + installedAt: "2026-07-31T00:00:00.000Z", + origin: "catalog", + }) + expect(written.ok).toBe(false) + if (!written.ok) expect(written.reason).toContain("v1 writer would silently drop them") + expect(readRaw()).toBe(before) + }) +}) + +// ── ③ 篡改 / 悬空 / 冲突 / 未知 child 一律响亮失败 ───────────────────────────────────────────── + +describe("REQ-128 #706 —— 篡改的 V3 账本:所有写路径拒绝且字节零改动", () => { + // 负向夹具纪律:违规项**从不是第一个**,并且集合里同时有合法项 —— 「只检查第一个」必须红。 + const mutate = (edit: (ledger: Record) => void): string => { + seedV3Ledger() + const ledger = JSON.parse(readRaw()) as Record + edit(ledger) + fs.writeFileSync(ledgerFile(), `${JSON.stringify(ledger, null, 2)}\n`) + return readRaw() + } + + const everyWritePathRefuses = (before: string, needle: string): void => { + const probe = probeLedgerForWrite(root) + expect(probe.ok).toBe(false) + const upsert = upsertRecordV2(root, upsertInput("skill", "late-arrival")) + expect(upsert.ok).toBe(false) + if (!upsert.ok) expect(upsert.reason).toContain(needle) + expect(removeRecordV2(root, "skill", "solo-skill").ok).toBe(false) + expect(setDesiredStateV2(root, "skill", "solo-skill", "disabled").ok).toBe(false) + expect(applyPackageMutation(root, packageMutation()).ok).toBe(false) + expect(releaseStandaloneClaim(root, "skill", "solo-skill").ok).toBe(false) + expect(readRaw()).toBe(before) // 拒绝 = 原文件一个字节都不动 + } + + test("被篡改的 graph 节点(digest 不再自洽)", () => { + const before = mutate((ledger) => { + const graphs = ledger.packageGraphs as PackageGraphV1[] + graphs[0]!.children[1]!.name = "hijacked" // 第二个 child —— 不是第一个 + }) + everyWritePathRefuses(before, "does not match the graph contents") + }) + + test("认不出的 owner token(混在合法 owner 后面)", () => { + const before = mutate((ledger) => { + const claims = ledger.claims as PackageClaimV1[] + claims[claims.length - 1]!.owners = [LEGACY_PROTECTED_OWNER, "bundle:evil"] // 违规项在第二位 + }) + everyWritePathRefuses(before, "unrecognised owner token") + }) + + test("dangling claim:claim 指向一个没有 record 的 child", () => { + const before = mutate((ledger) => { + const claims = ledger.claims as PackageClaimV1[] + claims.push({ kind: "skill", name: "never-installed", owners: [standaloneOwner("skill", "never-installed")] }) + }) + everyWritePathRefuses(before, "dangling claim") + }) + + test("unknown child:图里的节点没有 claim 认领", () => { + const before = mutate((ledger) => { + const claims = ledger.claims as PackageClaimV1[] + const victim = SHARED[2]! // 第三个组件 —— 不是 root、也不是第一个 child + ledger.claims = claims.filter((c) => !(c.kind === victim.kind && c.name === victim.name)) + }) + everyWritePathRefuses(before, "unknown child") + }) + + test("孤儿 bundle owner:claim 指名一张账本里没有的图", () => { + const before = mutate((ledger) => { + const claims = ledger.claims as PackageClaimV1[] + claims[claims.length - 1]!.owners = [standaloneOwner(claims[claims.length - 1]!.kind, claims[claims.length - 1]!.name), bundleOwner("package:ghost", ROOT_DIGEST)] + }) + everyWritePathRefuses(before, "orphan owner") + }) + + test("V3 段在场但信封自称 v:2 —— 被半写过或被降级构建改过", () => { + const before = mutate((ledger) => { + ledger.v = 2 + }) + everyWritePathRefuses(before, "carries package graphs/claims") + }) + + test("graphBeforeDigest 对不上 ⇒ package mutation 拒绝,账本原样", () => { + // 「陈旧前像」必须是**真的会改写图**的那种,才谈得上冲突。图已经等于目标态时, + // 前像对不上只说明这是一次重放(下一个 describe 覆盖),不是冲突 —— 两者不可混。 + seedV3Ledger() + const before = readRaw() + const updated = { ...packageGraph(), envelopeDigest: `sha256:${"c".repeat(64)}` } + const updatedGraph: PackageGraphV1 = { ...updated, graphDigest: computeGraphDigest(updated) } + const stale: PackageLedgerMutationV1 = { + ...packageMutation(), + operation: "update", + graphBeforeDigest: `sha256:${"f".repeat(64)}`, // 账本上根本不是这张图 + graphAfter: updatedGraph, + packageRecord: { + ...packageMutation().packageRecord!, + envelopeDigest: updatedGraph.envelopeDigest, + graphDigest: updatedGraph.graphDigest, + }, + } + const applied = applyPackageMutation(root, stale) + expect(applied.ok).toBe(false) + if (!applied.ok) expect(applied.reason).toContain("does not match the expected before-image") + expect(readRaw()).toBe(before) + }) +}) + +// ── ④ legacy / 身份不确定的内容永不被自动回收 ───────────────────────────────────────────────── + +describe("REQ-128 #706 —— legacy / unmanaged 内容永不 GC", () => { + test("V3 激活时,账本里已有的、没人认领的存量一律 legacy-protected(不猜它属于哪个历史 Bundle)", () => { + const seeded = upsertRecordsV2(root, [upsertInput("skill", "ancient-skill"), upsertInput("agent", "ancient-agent")]) + expect(seeded.ok).toBe(true) + expect(parsedLedger().v).toBe(2) // 还没装过 package ⇒ 不凭空造 claim + expect(parsedLedger().claims).toBeUndefined() + + const applied = applyPackageMutation(root, packageMutation()) + expect(applied.ok).toBe(true) + expect(packageClaimOwners(root, "skill", "ancient-skill")).toEqual([LEGACY_PROTECTED_OWNER]) + expect(packageClaimOwners(root, "agent", "ancient-agent")).toEqual([LEGACY_PROTECTED_OWNER]) + }) + + test("v1 存量迁移进 V3 账本一律 legacy-protected,且释放 standalone claim 不会把它摘掉", () => { + seedV3Ledger() + // v1-only receipt(没有 v2 record)—— 迁移会把它收编。 + const raw = JSON.parse(readRaw()) as { receipts: unknown[] } + raw.receipts.push({ + id: "skill:from-v1", + name: "from-v1", + type: "skill", + scope: "global", + installedAt: "2026-07-31T00:00:00.000Z", + origin: "catalog", + }) + fs.writeFileSync(ledgerFile(), `${JSON.stringify(raw, null, 2)}\n`) + + const migrated = migrateV1Ledger(root, "prod") + expect(migrated.ok).toBe(true) + if (migrated.ok) expect(migrated.migrated).toBe(1) + expect(packageClaimOwners(root, "skill", "from-v1")).toEqual([LEGACY_PROTECTED_OWNER]) + + // 释放「用户那一份」对 legacy-protected 是空操作 —— 保护不会被顺手摘掉。 + const released = releaseStandaloneClaim(root, "skill", "from-v1") + expect(released.ok).toBe(true) + expect(packageClaimOwners(root, "skill", "from-v1")).toEqual([LEGACY_PROTECTED_OWNER]) + }) +}) + +// ── ⑤ exact replay:同一事务重放不得二次施加 claim mutation ──────────────────────────────────── + +describe("REQ-128 #706 —— 崩溃前滚的 exact replay", () => { + test("同一份 mutation 重放:第二次报 replayed,账本字节逐字相同,零额外提交", () => { + const seeded = upsertRecordsV2(root, SOLO.map((c) => upsertInput(c.kind, c.name))) + expect(seeded.ok).toBe(true) + + const first = applyPackageMutation(root, packageMutation()) + expect(first).toMatchObject({ ok: true, replayed: false }) + const afterFirst = readRaw() + + const writes = countLedgerWrites() + const second = applyPackageMutation(root, packageMutation()) + expect(second).toMatchObject({ ok: true, replayed: true }) + expect(writes()).toBe(0) // 重放不写盘 + expect(readRaw()).toBe(afterFirst) + for (const c of SHARED) expect(packageClaimOwners(root, c.kind, c.name)).toEqual([OWNER]) + }) +}) diff --git a/packages/ui-mac/src/main/ext-receipt-v2.ts b/packages/ui-mac/src/main/ext-receipt-v2.ts index 6eaf5b6d784a..4366f3d843b7 100644 --- a/packages/ui-mac/src/main/ext-receipt-v2.ts +++ b/packages/ui-mac/src/main/ext-receipt-v2.ts @@ -378,9 +378,30 @@ export function probeLedgerForWrite(root: string): { ok: true } | { ok: false; r return { ok: false, reason: `install ledger corrupt (non-array records/receipts) — refusing to write; inspect ${ledgerPath(root)}` } const v3 = envelopeV3Sections(raw as Record, ledgerPath(root)) if (!v3.ok) return { ok: false, reason: `${v3.reason} — refusing to write` } + // REQ-128 `#706`:**预检也要判 V3 整体不变量,和落盘那一刻用同一个判据**。这个探针是各安装 + // 路径在动任何实物之前问的那一句「我等下记得下来吗」;只验信封而不验不变量,就会出现 + //「探针放行 → 文件/配置/密钥全写完 → writeLedgerFile 拒 → 装了但记不下来」—— + // 与本票要消灭的「先删实物、后判决」是同一形态,只是发生在安装侧。 + const invariants = validateV3State({ + recordKeys: recordKeysOf(Array.isArray((raw as { records?: unknown }).records) ? ((raw as { records: unknown[] }).records) : []), + packageGraphs: v3.packageGraphs, + claims: v3.claims, + }) + if (!invariants.ok) return { ok: false, reason: `refusing ledger write: ${invariants.reason}; inspect ${ledgerPath(root)}` } return { ok: true } } +/** claim 能指向哪些 child 的唯一判据 —— 探针与落盘共用一份,不许各算各的 + * (两份判据 = 预检放行而提交拒绝,正是本票在消灭的那种半态)。损坏条目不替 claim 背书。 */ +function recordKeysOf(records: readonly unknown[]): Set { + const keys = new Set() + for (const r of records) { + const rec = r as { kind?: unknown; name?: unknown; schemaVersion?: unknown } + if (typeof rec?.kind === "string" && typeof rec?.name === "string" && rec.schemaVersion === RECORD_SCHEMA_VERSION) keys.add(key(rec.kind, rec.name)) + } + return keys +} + /** REQ-128 `#706`:V3 段的信封级严格读取。**任何一条解不开就整本拒** —— * claim 决定「这个 child 还能不能删」,解不开的 claim 无法证明它说的不是当前这个 child, * 所以这里没有「排除坏条目继续用」这条路(那正是 owner 集合被悄悄削弱的方式)。 @@ -654,13 +675,7 @@ function writeLedgerFile( ): { ok: true; projectionLag?: string } | { ok: false; reason: string } { // REQ-128 `#706`:V3 不变量在**落盘之前**判 —— 一次校验、一次 rename。dangling claim / // unknown child / 孤儿 owner 一旦 durable 就再也无法自证,所以这里宁可整次拒写。 - // recordKeys 只认已解码的 record(损坏条目由各闸单独拒绝,不能替 claim 背书)。 - const recordKeys = new Set() - for (const r of records) { - const rec = r as { kind?: unknown; name?: unknown; schemaVersion?: unknown } - if (typeof rec?.kind === "string" && typeof rec?.name === "string" && rec.schemaVersion === RECORD_SCHEMA_VERSION) recordKeys.add(key(rec.kind, rec.name)) - } - const invariants = validateV3State({ recordKeys, packageGraphs: v3.packageGraphs, claims: v3.claims }) + const invariants = validateV3State({ recordKeys: recordKeysOf(records), packageGraphs: v3.packageGraphs, claims: v3.claims }) if (!invariants.ok) return { ok: false, reason: `refusing ledger write: ${invariants.reason}` } try { fs.mkdirSync(root, { recursive: true }) @@ -1275,6 +1290,13 @@ export function applyPackageMutation(root: string, mutation: PackageLedgerMutati const warnings: string[] = [...parsed.recordWarnings, ...parsed.receiptWarnings] if (parsed.corruptRecords.unattributable) return { ok: false, reason: `refusing package mutation: ledger holds an unattributable corrupt v2 record (fail closed — inspect ${ledgerPath(root)})` } + // REQ-128 `#706`:**入账前先验账本自身**,而且要在 exact-replay 短路**之前**。 + // 否则一本被篡改成 dangling claim / unknown child / 孤儿 owner 的账本,只要图恰好已是目标态, + // 崩溃前滚就会拿到 `replayed: true` —— 事务 journal 就此转终态,而这本账此后任何写都会被拒。 + // 「我什么都没做所以我成功了」是 owner 集合最贵的一种谎报。 + const inbound = validateV3State({ recordKeys: recordKeysOf(parsed.records), packageGraphs: parsed.packageGraphs, claims: parsed.claims }) + if (!inbound.ok) + return { ok: false, reason: `refusing package mutation: ledger state is not self-consistent: ${inbound.reason}; inspect ${ledgerPath(root)}` } const existingGraph = mutation.packageRecord ? (parsed.packageGraphs.find((g) => g.packageId === mutation.packageRecord!.packageId) ?? null) From d10495d1f7af56885174de477f6490bdb063a725 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 00:22:45 -0400 Subject: [PATCH 4/9] test: #706 cover the third config writer; state the write-counter's real limits --- packages/ui-mac/src/main/ext-config.test.ts | 22 +++++++++++++++++++ .../main/ext-package-ledger-uninstall.test.ts | 11 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/ui-mac/src/main/ext-config.test.ts b/packages/ui-mac/src/main/ext-config.test.ts index 7d095c38f76f..8bb46c54ff61 100644 --- a/packages/ui-mac/src/main/ext-config.test.ts +++ b/packages/ui-mac/src/main/ext-config.test.ts @@ -516,6 +516,28 @@ describe("removePluginPath — 主+legacy 全源净除,引擎语义匹配,strict expect(pluginsOf(homeCfg())).toEqual([]) // legacy file:// 等价形态同扫 }) + // REQ-128 `#706`:第三个配置写器同样**不碰账本**。前两条(removeMcp / removePlugin)已各有 + // 字节零改动的钉子;这一条补上 vendored-path 那支 —— 少了它,把内层 `removeReceipt` 接回 + // `removePluginPathUnlocked` 一处,整仓没有任何测试会红。 + test("REQ-128 #706:撤 vendored plugin 路径条目与账本彻底分家 —— installs.json 字节零改动", () => { + fs.mkdirSync(alphaTmp, { recursive: true }) + const target = jsOf("vp@cccc") + fs.writeFileSync(mainCfg(), JSON.stringify({ plugin: [target] })) + addReceipt(alphaTmp, { + id: "plugin:vp", + name: "vp", + type: "plugin", + scope: "global", + installedAt: new Date().toISOString(), + origin: "catalog", + configKey: `plugin-path:${target}`, + }) + const before = fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8") + expect(removePluginPath("vp", target).ok).toBe(true) + expect(fs.readFileSync(path.join(alphaTmp, "installs.json"), "utf8")).toBe(before) + expect(readLedger(alphaTmp).receipts.some((x) => x.name === "vp")).toBe(true) + }) + test("语法损坏 / 非对象根 / plugin 非数组 → fail-closed 拒(不删条目也不谎报成功)", () => { fs.mkdirSync(alphaTmp, { recursive: true }) const target = jsOf("vp@bbbb") diff --git a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts index 504d91ab7725..7807edc974cb 100644 --- a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts +++ b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts @@ -12,6 +12,12 @@ // ② **每条路径只有一次 ledger mutation。** 用 `spyOn(fs.renameSync)` 数落到 installs.json 的 // 原子换名 —— `writeFileAtomicSync` 每次提交恰好一次 rename,所以这是对物理提交次数的直接 // 测量,不是对最终内容的推断(内容对幂等的重复写不敏感,数不出双写)。 +// **它抓得到什么、抓不到什么(实测,别高估它)**:在卸载尾部插一次 `releaseStandaloneClaim` +// 再 `removeRecordV2`,skill/agent 两条立刻红 —— 两次真写它数得出来。但再插一次 +// `removeRecordV2` 它**不红**:record 已经没了,第二次是幂等 no-op,一个字节都不写。 +// 也就是说这道闸管的是「重复落盘」,不是「重复调用」。**内层 receipt 副作用有没有被接回来** +// 不归它管 —— 那由 `ext-fs-installer.test.ts` / `ext-config.test.ts` 里的字节零改动钉子看着 +// (本文件用的是注入的假 installer,production 的那几个函数根本不在这条链上)。 // // ③ **篡改/悬空/冲突一律响亮失败且字节零改动。** 负向夹具里违规项**从不放在第一个**,并且集合里 // 同时有合法项 —— 「检查每一个」写成「检查第一个」时必须红。 @@ -288,9 +294,12 @@ describe("REQ-128 #706 —— 真实卸载:仍有 Bundle owner ⇒ 实物一件 const writes = countLedgerWrites() const outcome = await uninstallByKey({ type: kind, name, scope: "global" }, makeDeps(calls)) - expect(outcome).toMatchObject({ ok: true, retainedForOwners: [OWNER] }) + // 实物先判:把 `planDirectUninstall` 拿掉时,生产代码自己会说 + // 「ledger removal failed … artifacts already removed; retry (idempotent)」—— 而重试永远 + // 不会成功(Bundle owner 不会自己消失)。所以这两行是本票 Blocker 的直接回归。 expect(calls).toEqual([]) // 没有任何 installer 被调用 = 一件实物都没动 if (artifactExisted) expect(fs.existsSync(artifact)).toBe(true) + expect(outcome).toMatchObject({ ok: true, retainedForOwners: [OWNER] }) expect(writes()).toBe(1) // 只有「释放 claim」这一次提交 // 账本:record 仍在(它还属于这个 package)、图逐字不变、claim 只少了用户那一份。 expect(findRecordV2(root, kind, name)).not.toBeNull() From 574b925e002dd34dae9711fa9af881ec4f3439a2 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 00:27:23 -0400 Subject: [PATCH 5/9] fix: #706 graph root reads the declared role, not components[0] --- packages/ui-mac/src/main/package-admission.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui-mac/src/main/package-admission.ts b/packages/ui-mac/src/main/package-admission.ts index 2298193dc2d0..b6c288c5b4c9 100644 --- a/packages/ui-mac/src/main/package-admission.ts +++ b/packages/ui-mac/src/main/package-admission.ts @@ -453,11 +453,15 @@ function packagePlan( } /** - * REQ-128 `#706`:package 安装的**有效安装图**。本期合同只允许单组件,所以 root 就是唯一 - * 组件、`children` 为空;`#697` 放开多组件后本函数换成逐组件构造,图文法与 digest 口径不变。 + * REQ-128 `#706`:package 安装的**有效安装图**。本期只装 root 一个组件,所以 `children` 为空; + * `#697` 放开多组件后本函数换成逐组件构造,图文法与 digest 口径不变。 + * + * root 走 `rootComponentOf`(声明的 `role`),**不是 `components[0]`** —— 后者是生产者随手排的 + * 顺序,把它当 root 就是把一个排版约定升级成承重不变量。这个 componentId 会直接进 owner token + * 的派生链,认错了 = claim 归属认错人。`#749` 刚在隔壁把同一处缺陷删掉,别在这里再种一次。 */ function packageGraphOf(prepared: PreparedPackage, manifestDigest: string): PackageGraphV1 { - const component = prepared.facts.envelope.components[0]! + const component = rootComponentOf(prepared.facts) const withoutDigest = { packageId: prepared.facts.envelope.prelude.packageId, envelopeDigest: `sha256:${prepared.binding.envelopeDigest}`, From e2cf24f7199b8e469b023aab7df148d8980f77fa Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 01:15:08 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20#706=20bundle-only=20=E5=8D=B8?= =?UTF-8?q?=E8=BD=BD=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review #757 Major。用户对一个「只属于某个 Bundle、自己从没单装过」的条目点删除, 界面显示「已移除」,而文件、record、图、claim 一件没动 —— 刷新后条目依旧。 链路:`package-admission` 的 claimMutations 只 acquire 一个 `bundle:` owner, standalone owner 要等用户自己再单装一次才出现。而 `directUninstallVerdict` 只看 「有没有 Bundle owner」就选 `release-claim-only`,不问那份 standalone claim 在不在; `releaseStandaloneClaim` 对不存在的 owner 仍无条件提交一次写并回 ok;planner 回 `ok:true`;Hub 只看 `ok` ⇒ 显示「已移除」。 修法(判决在写盘之前,三处独立成闸): - `directUninstallVerdict` 拆出第三支 `refuse` —— 有 Bundle owner 但没有 standalone owner = 没有可释放的东西,响亮拒绝; - `planDirectUninstall` 与 `removeRecordV2` 各自消费 `refuse`(后者若漏,去账会留下 指向不存在 record 的 dangling claim); - `releaseStandaloneClaim` 自带前置:没有可释放的 owner 就在写盘之前失败,不依赖 调用方先做对判决。 「双 owner 时只释放 standalone」的行为原样保留。 闸:`ext-package-ledger-uninstall.test.ts` 新增 bundle-only 组(5 种 kind × ok:false + 零 rename + 字节零改动 + 零 installer 调用 + 实物仍在 + claim 原样),以及 `releaseStandaloneClaim` 直呼拒绝;`ext-package-ledger-v3.test.ts` 补 verdict 层 bundle-only 判决(违规形状不放集合首位)。修复前这些全红,实测 planner 回 `ok:true` + 一次 rename。 更正上一轮报告:那里写「`release-claim-only` 分支保证 claim 必然存在,所以 `releaseStandaloneClaim` 的无条件写盘不可达」—— 该前提为假,fresh package 安装后 只有 Bundle owner。因此 legacy-protected 直呼 `releaseStandaloneClaim` 的语义 一并从「静默成功的空操作」改为「响亮拒绝且零落盘」(生产从不走这条,保护不变)。 Refs #706 --- .../main/ext-package-ledger-uninstall.test.ts | 75 ++++++++++++++++++- .../src/main/ext-package-ledger-v3.test.ts | 18 +++++ .../ui-mac/src/main/ext-package-ledger-v3.ts | 18 ++++- packages/ui-mac/src/main/ext-receipt-v2.ts | 20 ++++- 4 files changed, 126 insertions(+), 5 deletions(-) diff --git a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts index 7807edc974cb..cee42bf87a23 100644 --- a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts +++ b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts @@ -334,6 +334,72 @@ describe("REQ-128 #706 —— 真实卸载:没有别的 owner ⇒ 正常删,且 } }) +describe("REQ-128 #706 —— 真实卸载:**只有** Bundle owner(用户从没单装过)⇒ 响亮失败", () => { + // 这是 fresh package 安装后的**默认**形状:`packageMutationEnvelope` 只 acquire 一个 + // `bundle:` owner,standalone owner 要等用户自己再单装一次才会出现。上一版把「有 Bundle + // owner」直接当成「释放 standalone claim」,而那份 claim 根本不存在 —— `withoutOwner` 原样 + // 返回、`releaseStandaloneClaim` 照旧提交一次、planner 回 `ok:true`,Hub 只看 `ok` ⇒ 显示 + // 「已移除」,而文件、record、图、claim 一件没动。用户刷新后条目还在。 + // + // 四条断言各钉一件事:失败(不是 ok:true)/ 零落盘(字节与 rename 次数)/ 零实物副作用 / + // Hub 拿不到会显示「已移除」的结果。 + for (const { kind, name } of SHARED) { + test(`${kind}:${name} —— ok:false、字节零改动、零实物副作用、claim 原样`, async () => { + seedV3Ledger() + // 前置事实:这就是 fresh 安装的形状 —— owner 集合里只有 Bundle。 + expect(packageClaimOwners(root, kind, name)).toEqual([OWNER]) + const before = readRaw() + const graphsBefore = readPackageGraphs(root) + const artifact = artifactPath(kind, name) + const artifactExisted = fs.existsSync(artifact) + + const calls: Calls = [] + const writes = countLedgerWrites() + const outcome = await uninstallByKey({ type: kind, name, scope: "global" }, makeDeps(calls)) + + // ① 失败,且理由说得出「由 Bundle 拥有、没有可释放的独立安装」。 + expect(outcome.ok).toBe(false) + if (!outcome.ok) { + expect(outcome.reason).toContain(OWNER) + expect(outcome.reason).toContain("no standalone install to release") + } + // ② Hub 只读 `ok`(extension-hub.tsx `onUninstall`):ok:false ⇒ 只可能显示失败。 + expect((outcome as { retainedForOwners?: string[] }).retainedForOwners).toBeUndefined() + // ③ 零落盘:一次 rename 都没有,且文件逐字节不变。 + expect(writes()).toBe(0) + expect(readRaw()).toBe(before) + // ④ 零实物副作用:没有任何 installer 被调用,实物仍在。 + expect(calls).toEqual([]) + if (artifactExisted) expect(fs.existsSync(artifact)).toBe(true) + // 账本语义面同样原样:record 在、图逐字不变、claim 一个 owner 都没少。 + expect(findRecordV2(root, kind, name)).not.toBeNull() + expect(readPackageGraphs(root)).toEqual(graphsBefore) + expect(packageClaimOwners(root, kind, name)).toEqual([OWNER]) + }) + } + + test("releaseStandaloneClaim 被直接调用时同样拒绝(没有可释放的 owner ⇒ 不写盘)", () => { + seedV3Ledger() + // 违规项不放第一个:取 SHARED 的最后一个 child,且账本里同时有一堆合法的 solo claim。 + const victim = SHARED[SHARED.length - 1]! + const before = readRaw() + const writes = countLedgerWrites() + + const released = releaseStandaloneClaim(root, victim.kind, victim.name) + + expect(released.ok).toBe(false) + if (!released.ok) expect(released.reason).toContain("no standalone claim to release") + expect(writes()).toBe(0) + expect(readRaw()).toBe(before) + expect(packageClaimOwners(root, victim.kind, victim.name)).toEqual([OWNER]) + // 合法的那一支不受影响:solo child 的 standalone claim 照样释放得掉。 + const solo = SOLO[SOLO.length - 1]! + const ok = releaseStandaloneClaim(root, solo.kind, solo.name) + expect(ok.ok).toBe(true) + expect(packageClaimOwners(root, solo.kind, solo.name)).toEqual([]) + }) +}) + describe("REQ-128 #706 —— 只有一次 mutation:v:2 账本上的内层副作用探测器", () => { // V3 未激活时,`alpha-installs` 的 v1 写器不会被 `refuseIfV3` 挡住 —— 于是「内层 removeReceipt // 有没有被接回来」在这里是**可见的**:它会多出一次 rename。v:3 夹具上测不出这一条(v1 写器被 @@ -542,9 +608,14 @@ describe("REQ-128 #706 —— legacy / unmanaged 内容永不 GC", () => { if (migrated.ok) expect(migrated.migrated).toBe(1) expect(packageClaimOwners(root, "skill", "from-v1")).toEqual([LEGACY_PROTECTED_OWNER]) - // 释放「用户那一份」对 legacy-protected 是空操作 —— 保护不会被顺手摘掉。 + // 释放「用户那一份」摘不掉 legacy 保护。review #757 Major 之后这不再是**静默成功的空操作**: + // 没有可释放的 owner 就在写盘之前响亮失败(空操作 + ok 正是「谎报已移除」的燃料)。 + // 用户真要卸载它走的是另一支:legacy-protected 不是 Bundle,不阻挡显式直接卸载。 + const before = readRaw() const released = releaseStandaloneClaim(root, "skill", "from-v1") - expect(released.ok).toBe(true) + expect(released.ok).toBe(false) + if (!released.ok) expect(released.reason).toContain("no standalone claim to release") + expect(readRaw()).toBe(before) expect(packageClaimOwners(root, "skill", "from-v1")).toEqual([LEGACY_PROTECTED_OWNER]) }) }) diff --git a/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts b/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts index 45967deb4e8a..bb8c15e7dfd7 100644 --- a/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts +++ b/packages/ui-mac/src/main/ext-package-ledger-v3.test.ts @@ -249,6 +249,24 @@ describe("REQ-128 #706 — claim 集合代数与直接卸载判决", () => { directUninstallVerdict(claim("skill", "demo", [standaloneOwner("skill", "demo"), bundleOwner("skill:demo", D2)]), "skill", "demo"), ).toEqual({ decision: "release-claim-only", remainingOwners: [bundleOwner("skill:demo", D2)] }) }) + + // review #757 Major:上一版只覆盖「standalone + Bundle」,而 fresh package 安装产出的是 + // **只有 Bundle owner**(`package-admission` 的 claimMutations 只 acquire 一个 `bundle:`)。 + // 那个形状被判成 `release-claim-only` ⇒ 上层去释放一份不存在的 claim ⇒ 谎报卸载成功。 + test("直接卸载判决:**只有** Bundle owner(没有 standalone claim 可释放)⇒ refuse,不是 release-claim-only", () => { + // 违规形状不放在 owner 集合的第一位:多个 Bundle owner 在场,自己那份自始至终不存在。 + const verdict = directUninstallVerdict(claim("skill", "demo", [bundleOwner("kit:a", D1), bundleOwner("kit:b", D2)]), "skill", "demo") + expect(verdict.decision).toBe("refuse") + if (verdict.decision === "refuse") { + expect(verdict.reason).toContain(bundleOwner("kit:a", D1)) + expect(verdict.reason).toContain(bundleOwner("kit:b", D2)) + expect(verdict.reason).toContain("no standalone install to release") + } + // legacy-protected 混在 Bundle owner 里同样不是「自己那份」—— 仍然 refuse。 + expect(directUninstallVerdict(claim("skill", "demo", [LEGACY_PROTECTED_OWNER, bundleOwner("skill:demo", D2)]), "skill", "demo").decision).toBe( + "refuse", + ) + }) }) describe("REQ-128 #706 — 落盘前的整体不变量", () => { diff --git a/packages/ui-mac/src/main/ext-package-ledger-v3.ts b/packages/ui-mac/src/main/ext-package-ledger-v3.ts index 841f19366fc2..6c96e051654c 100644 --- a/packages/ui-mac/src/main/ext-package-ledger-v3.ts +++ b/packages/ui-mac/src/main/ext-package-ledger-v3.ts @@ -399,14 +399,28 @@ export const blockingOwners = (owners: readonly string[], excluding: string): st export type DirectUninstallVerdict = | { decision: "delete"; releasedOwner: string | null } | { decision: "release-claim-only"; remainingOwners: string[] } + | { decision: "refuse"; reason: string } /** 直接(用户发起的)卸载判决。**必须在删任何实物之前调用** —— repository 事后拒绝时实物 - * 已经没了,用户看到「卸载失败」而东西是真没了,这正是 V3 要消灭的那种半态。 */ + * 已经没了,用户看到「卸载失败」而东西是真没了,这正是 V3 要消灭的那种半态。 + * + * 三支互斥,**「有 Bundle owner」不等于「有 standalone claim 可释放」**(review #757 Major): + * fresh package 安装只 acquire 一个 `bundle:` owner(`package-admission` 的 claimMutations), + * standalone owner 要等用户自己再单装一次才出现。把这两件事混成一支 ⇒ 用户对一个纯 Bundle + * child 点卸载,系统释放一份根本不存在的 claim、照旧提交一次写、回 ok ⇒ Hub 显示「已移除」 + * 而文件/record/图/claim 全在。所以这里必须拆出第三支:**没有可释放的东西就响亮拒绝**。 */ export function directUninstallVerdict(claim: PackageClaimV1 | null, kind: string, name: string): DirectUninstallVerdict { if (!claim) return { decision: "delete", releasedOwner: null } const own = standaloneOwner(kind, name) const blocking = blockingOwners(claim.owners, own) - if (blocking.length > 0) return { decision: "release-claim-only", remainingOwners: [...claim.owners.filter((o) => o !== own)].sort() } + if (blocking.length > 0) { + if (!claim.owners.includes(own)) + return { + decision: "refuse", + reason: `${kind}:${name} is owned by ${[...blocking].sort().join(", ")} and has no standalone install to release — uninstall the package instead`, + } + return { decision: "release-claim-only", remainingOwners: [...claim.owners.filter((o) => o !== own)].sort() } + } return { decision: "delete", releasedOwner: claim.owners.includes(own) ? own : null } } diff --git a/packages/ui-mac/src/main/ext-receipt-v2.ts b/packages/ui-mac/src/main/ext-receipt-v2.ts index 4366f3d843b7..443d590f5c8d 100644 --- a/packages/ui-mac/src/main/ext-receipt-v2.ts +++ b/packages/ui-mac/src/main/ext-receipt-v2.ts @@ -1029,6 +1029,9 @@ export function removeRecordV2(root: string, kind: InstallReceiptType, name: str // 它会在**删任何实物之前**给出「只释放 claim」的判决;走到这里还被拒 = 调用方漏问了。 const claim = findClaim(parsed.claims, kind, name) const verdict = directUninstallVerdict(claim, kind, name) + // review #757 Major:`refuse`(只有 Bundle owner、没有 standalone claim)同样不得去账 —— + // 去了账,claim 依旧指着一个不存在的 record。只有 `delete` 一支能往下走。 + if (verdict.decision === "refuse") return { ok: false, reason: `refusing to remove ${k}: ${verdict.reason}` } if (verdict.decision === "release-claim-only") return { ok: false, @@ -1066,6 +1069,10 @@ export function planDirectUninstall(root: string, kind: InstallReceiptType, name if (readError) return { ok: false, reason: `${readError} — refusing to uninstall` } if (corrupt) return { ok: false, reason: `installs.json unreadable: ${ledgerPath(root)} — refusing to uninstall` } const verdict = directUninstallVerdict(findClaim(parsed.claims, kind, name), kind, name) + // review #757 Major:纯 Bundle 拥有(用户从没单装过)⇒ 这里没有任何可释放的东西,必须在 + // 写盘之前失败。上一版把它折进 `release-claim-only`,于是卸载在什么都没发生的情况下回 ok, + // 而 Hub 只看 `ok` ⇒ 显示「已移除」。 + if (verdict.decision === "refuse") return { ok: false, reason: verdict.reason } return verdict.decision === "delete" ? { ok: true, decision: "delete" } : { ok: true, decision: "release-claim-only", remainingOwners: verdict.remainingOwners } } @@ -1074,7 +1081,18 @@ export function releaseStandaloneClaim(root: string, kind: InstallReceiptType, n const { parsed, corrupt, readError } = parseLedger(root) if (readError) return { ok: false, reason: `${readError} — refusing to write` } if (corrupt) return { ok: false, reason: `installs.json unreadable: ${ledgerPath(root)} — refusing to write` } - const claims = withoutOwner(parsed.claims, kind, name, standaloneOwner(kind, name)) + // review #757 Major:释放一份不存在的 claim 曾经**照旧提交一次写**并回 ok —— 调用方据此 + // 报告「已移除」。上一轮以「`release-claim-only` 分支保证 claim 必然存在」为由没堵这个口子, + // 而那个前提是假的(fresh package 只有 Bundle owner)。这里独立成闸:没有可释放的 owner + // 就在写盘之前失败,不依赖任何调用方先做对判决。 + const own = standaloneOwner(kind, name) + const existing = findClaim(parsed.claims, kind, name) + if (!existing?.owners.includes(own)) + return { + ok: false, + reason: `no standalone claim to release for ${kind}:${name} (owners: ${existing ? existing.owners.join(", ") : "none"}) — refusing to write`, + } + const claims = withoutOwner(parsed.claims, kind, name, own) const written = writeLedgerFile( root, [...parsed.receipts, ...parsed.rawInvalidReceipts], From e205674185382ddadf580945255afbb447dcf3fc Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Sat, 1 Aug 2026 01:21:12 -0400 Subject: [PATCH 7/9] =?UTF-8?q?test:=20#706=20=E7=9C=8B=E4=BD=8F=20removeR?= =?UTF-8?q?ecordV2=20=E9=82=A3=E9=81=93=E6=9C=80=E5=90=8E=E7=9A=84=20claim?= =?UTF-8?q?-aware=20=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查自己新加的闸能不能被绕过时发现的:把 `removeRecordV2` 里整段 claim-aware 判决 删掉,`bun test src` 一条都不红 —— 它此前是一道没人看着的闸,可以被静默删除。 而上一个提交让它的失败模式变得更坏:新增的 `refuse` 一支若无人消费,就会掉进 `delete` 支,record 与整条 claim 一起被去掉,而那个 Bundle 的图还指着它。 新增一条覆盖三种形状的闸:纯 Bundle 拥有(refuse 支,违规项不取集合首位)、 standalone + Bundle 并存(release-claim-only 支)、以及没有 Bundle 的 solo child 必须仍然去得了账(证明这不是「一律拒」)。前两支各断言字节零改动。 实测:分别删掉 `refuse` 与 `release-claim-only` 两支,这条各红一次。 Refs #706 --- .../main/ext-package-ledger-uninstall.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts index cee42bf87a23..50e205ce4284 100644 --- a/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts +++ b/packages/ui-mac/src/main/ext-package-ledger-uninstall.test.ts @@ -378,6 +378,39 @@ describe("REQ-128 #706 —— 真实卸载:**只有** Bundle owner(用户从没 }) } + // `removeRecordV2` 里那道 claim-aware 闸是**最后一道**(正常路径由 `planDirectUninstall` 先挡)。 + // 实测:把它整段删掉,`bun test src` 一条都不红 —— 也就是说它此前是一道没人看着的闸,可以被 + // 静默删除。它值得被看着,因为 `refuse` 一支若无人消费就会掉进 `delete`:record 与整条 claim + // 一起没了,而那个 Bundle 的图还指着它 —— 从此这个 package 无法正确卸载。 + test("removeRecordV2 的最后一道闸:仍被 Bundle 拥有的 child 去不了账(两支都拒,且字节零改动)", () => { + seedV3Ledger() + // ① 纯 Bundle 拥有(refuse 支)。违规项不取集合首位。 + const bundleOnly = SHARED[SHARED.length - 1]! + expect(packageClaimOwners(root, bundleOnly.kind, bundleOnly.name)).toEqual([OWNER]) + let before = readRaw() + const refused = removeRecordV2(root, bundleOnly.kind, bundleOnly.name) + expect(refused.ok).toBe(false) + if (!refused.ok) expect(refused.reason).toContain("no standalone install to release") + expect(readRaw()).toBe(before) + expect(findRecordV2(root, bundleOnly.kind, bundleOnly.name)).not.toBeNull() + expect(packageClaimOwners(root, bundleOnly.kind, bundleOnly.name)).toEqual([OWNER]) + + // ② standalone + Bundle 并存(release-claim-only 支)—— 同样不得去账。 + const shared = SHARED[1]! + expect(upsertRecordsV2(root, [upsertInput(shared.kind, shared.name)]).ok).toBe(true) + before = readRaw() + const alsoRefused = removeRecordV2(root, shared.kind, shared.name) + expect(alsoRefused.ok).toBe(false) + if (!alsoRefused.ok) expect(alsoRefused.reason).toContain("release the standalone claim instead of dropping the record") + expect(readRaw()).toBe(before) + expect(findRecordV2(root, shared.kind, shared.name)).not.toBeNull() + + // ③ 对照:没有 Bundle 拥有的 solo child 照样去得了账 —— 这道闸不是「一律拒」。 + const solo = SOLO[SOLO.length - 1]! + expect(removeRecordV2(root, solo.kind, solo.name).ok).toBe(true) + expect(findRecordV2(root, solo.kind, solo.name)).toBeNull() + }) + test("releaseStandaloneClaim 被直接调用时同样拒绝(没有可释放的 owner ⇒ 不写盘)", () => { seedV3Ledger() // 违规项不放第一个:取 SHARED 的最后一个 child,且账本里同时有一堆合法的 solo claim。 From f5b254926221f9ca9ea6fa9e338c34be5f411902 Mon Sep 17 00:00:00 2001 From: orchestrator Date: Sat, 1 Aug 2026 01:27:17 -0400 Subject: [PATCH 8/9] =?UTF-8?q?[#706][CODE]=20=E6=8A=8A=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E6=96=B0=E8=B4=A6=E6=9C=AC=E9=97=B8=E9=97=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E8=BF=9B=20gate-files.tsv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现方实测:这两个文件整个删掉零红,即可被静默删除。它以「登记簿的操作性判据是 断言自己模块之外的东西」为由未登记,并升给编排者裁决。 裁决:登记。判据的第一句才是本体——「删掉它就会移除某条具体保证,而不只是减少覆盖率」。 删掉 uninstall 那个文件移除的保证是「卸载属于 Bundle 的组件会拒绝而不是谎报成功」, 那是一条具体保证;而且实测删掉零红,正好落在判据的后果句上。 「断言模块之外」是发现用的启发,不是排他条件。 下界取实际条数(34 / 20),不留余量。 --- scripts/gate-files.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index f3044dd751c5..73fe2f690248 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -106,3 +106,5 @@ # ── opencode(仅 alpha 自有)──────────────────────────────────────────────── 40 packages/opencode test/tool/alpha-websearch-failure.test.ts packages/ui-mac/src/main/stream-read-hygiene.test.ts #647 readBoundedBody 读全 + MAX_BODY_BYTES 硬限 + #489/#223 失败诚实 4 packages/opencode test/permission/alpha-ask-deadline.test.ts - ADR-038/#668:v1 审批请求无人应答时在期限内**具名失败**(Exit 必须 failure = 绝不到点自动放行)+ 广播 reject 回执 + pending 清空 + 默认期限不被调成无期限 +34 packages/ui-mac src/main/ext-package-ledger-uninstall.test.ts - #706:直接卸载的 claim-aware 判决 —— bundle-only(无 standalone claim 可释放)必须写盘前拒绝而不是谎报 ok,双 owner 只释放 standalone,legacy-protected 永不 GC;并钉住 removeRecordV2 的三形状(拒绝支/只释放支/solo 仍去得了账)。下界=实际条数 34,不留余量:留余量则删一格仍能过 +20 packages/ui-mac src/main/ext-package-ledger-v3.test.ts - #706:V3 账本的严格解码与 owner 集合代数 —— owner token 三类、claim 派生 refcount、图/claim 篡改响亮失败、降级 fail-closed。下界=实际条数 20,不留余量 From 723d472a1d636c111ff1004e558dec43e819d68f Mon Sep 17 00:00:00 2001 From: orchestrator Date: Sat, 1 Aug 2026 01:35:00 -0400 Subject: [PATCH 9/9] =?UTF-8?q?[#706][CODE]=20=E8=A1=A5=E9=BD=90=E5=A7=94?= =?UTF-8?q?=E6=B4=BE=E9=93=BE:=E5=A1=AB=20delegates=5Fto,=E5=B9=B6?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E4=B8=A4=E4=B8=AA=E5=8F=97=E6=89=98=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记 ext-package-ledger-uninstall.test.ts 之后,登记簿的绊线立刻红了—— 它正文里明写「内层 receipt 副作用有没有被接回来不归它管,那由 ext-fs-installer.test.ts / ext-config.test.ts 里的字节零改动钉子看着」,而这两个文件既没登记也没分类。 这正是登记簿设计的作用:委派必须显式,被委派方必须在册,否则「主判据在别处」这句话 本身就是假闸门。填上 delegates_to 并把两个受托方登记(下界=实际条数 39 / 93,不留余量)。 闸门文件 65 → 69。 --- scripts/gate-files.tsv | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index 73fe2f690248..47bd990b93fc 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -106,5 +106,7 @@ # ── opencode(仅 alpha 自有)──────────────────────────────────────────────── 40 packages/opencode test/tool/alpha-websearch-failure.test.ts packages/ui-mac/src/main/stream-read-hygiene.test.ts #647 readBoundedBody 读全 + MAX_BODY_BYTES 硬限 + #489/#223 失败诚实 4 packages/opencode test/permission/alpha-ask-deadline.test.ts - ADR-038/#668:v1 审批请求无人应答时在期限内**具名失败**(Exit 必须 failure = 绝不到点自动放行)+ 广播 reject 回执 + pending 清空 + 默认期限不被调成无期限 -34 packages/ui-mac src/main/ext-package-ledger-uninstall.test.ts - #706:直接卸载的 claim-aware 判决 —— bundle-only(无 standalone claim 可释放)必须写盘前拒绝而不是谎报 ok,双 owner 只释放 standalone,legacy-protected 永不 GC;并钉住 removeRecordV2 的三形状(拒绝支/只释放支/solo 仍去得了账)。下界=实际条数 34,不留余量:留余量则删一格仍能过 +34 packages/ui-mac src/main/ext-package-ledger-uninstall.test.ts packages/ui-mac/src/main/ext-fs-installer.test.ts,packages/ui-mac/src/main/ext-config.test.ts #706:直接卸载的 claim-aware 判决 —— bundle-only(无 standalone claim 可释放)必须写盘前拒绝而不是谎报 ok,双 owner 只释放 standalone,legacy-protected 永不 GC;并钉住 removeRecordV2 的三形状(拒绝支/只释放支/solo 仍去得了账)。下界=实际条数 34,不留余量:留余量则删一格仍能过 20 packages/ui-mac src/main/ext-package-ledger-v3.test.ts - #706:V3 账本的严格解码与 owner 集合代数 —— owner token 三类、claim 派生 refcount、图/claim 篡改响亮失败、降级 fail-closed。下界=实际条数 20,不留余量 +39 packages/ui-mac src/main/ext-fs-installer.test.ts - #706 受托方:内层 receipt 副作用的字节零改动钉子 —— 卸载实物的那一层不得再碰账本。删掉它,「内层副作用被接回来」就无人看着(账本闸只数落盘次数,数不出幂等 no-op)。下界=实际条数 39,不留余量 +93 packages/ui-mac src/main/ext-config.test.ts - #706 受托方:配置写器(removeMcp/removePlugin/removePluginPath)的字节零改动钉子 —— 撤 config 与去账彻底分家。同上,删掉即失去该保证。下界=实际条数 93,不留余量