Skip to content
27 changes: 27 additions & 0 deletions packages/ui-mac/src/main/alpha-installs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 31 additions & 3 deletions packages/ui-mac/src/main/ext-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,20 +382,26 @@ 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", () => {
expect(persistPlugin("opencode-notify@0.3.1", { catalogId: "plugin:opencode-notify" }).ok).toBe(true)
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", () => {
Expand Down Expand Up @@ -510,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")
Expand Down
15 changes: 7 additions & 8 deletions packages/ui-mac/src/main/ext-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, unknown>): ConfigResult {
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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 }
}

Expand Down
36 changes: 30 additions & 6 deletions packages/ui-mac/src/main/ext-fs-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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", () => {
Expand Down
15 changes: 10 additions & 5 deletions packages/ui-mac/src/main/ext-fs-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down Expand Up @@ -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 }
}

Expand Down
24 changes: 23 additions & 1 deletion packages/ui-mac/src/main/ext-install-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ import {
computeGrantDigest,
findRecordV2,
lookupForUninstall,
planDirectUninstall,
projectScopeIdentity,
releaseStandaloneClaim,
removeRecordV2,
setDesiredStateV2,
probeLedgerForWrite,
Expand Down Expand Up @@ -276,7 +278,11 @@ export type CatalogInstallOutcome =
| { ok: false; stage: "authorize"; reason: string; authorization: CapabilityDiff[] }
| { ok: false; reason: string; stage?: Exclude<TxStage, "authorize"> }

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<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
const RECEIPT_TYPES = new Set<string>(["mcp", "skill", "agent", "command", "plugin", "bundle", "cloud"])
Expand Down Expand Up @@ -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)

Expand Down
11 changes: 6 additions & 5 deletions packages/ui-mac/src/main/ext-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 保留
Expand Down
Loading
Loading