[#809][CODE] plugin 事务与生命周期:kind 分流收口 + wrapper 模板 + buildPluginTxItems + 卸载臂 - #816
Conversation
…ms + 卸载臂
宿主端把一个 managed OpenCode Plugin 真正装进去、记上账、卸得掉。
- kind 分流收口:`package-admission.ts:506` 的兜底三元(`else → "mcp"`)换成
`packageChildKindV1` 单点表,与 `packageChildTxKeyV1` 共用同一张表;builder 分派
改成对 `PackageChildKindV1` 穷举的 switch;两处 `as "skill"|"agent"|"mcp"` 强转删掉
(预览面改走 `packageChildPreviewKeyV1`,不再把 `command:foo` 撞成 `mcp--foo`)。
- wrapper 模板(`managed-plugin-wrapper.ts`):顶层不 import 上游,
`import("./upstream.js")` 在 `server()` 函数体内 ⇒ V2 `ConfigExternalPlugin` 只求值到
一个零副作用空壳后解码失败,第三方字节从头到尾没被 V2 求值过。
- `buildPluginTxItems`:两条 file item(`plugin--<name>--f0/f1`)+ 一条写整个
`plugin` 数组的 config item,同一次事务;不复用也不改 `installPluginFromCas`。
- 卸载臂:`removePackageChildArtifactsV1` 新增 plugin 分支(账本 `plugin-path:` configKey
是唯一落点真源,越出 plugins 根即拒),`PackageArtifactInstallersV1` 加 `removePluginPath`。
Refs #699
486a4de to
2089838
Compare
M1 目录删除失败被谎报成功(`ext-package-uninstall.ts`)
`rmSync` 的异常此前落在**空 catch** 里:`removed` 仍记作已删、grants 照删、root mutation
照提交、返回 `ok:true` —— 界面说卸载成功,而文件还在盘上、账本记录已经没了,用户连「该重试
什么」都不知道。改成**整体失败并立刻返回**,账本一个字节不动;reason 只带 errno,不带绝对
路径(它会过 IPC 到 renderer)。判据是一条**真实磁盘失败**用例(chmod 掉 plugins 根的写权限,
**不 mock `rmSync`**):卸载失败 + 目录还在 + 账本逐字未动 + 恢复权限后重试收敛。
M2 `server()` 转发闸把 canary 输出抄成了同一常量(`managed-plugin-wrapper.cases.ts`)
旧判据 = 写死键集 + `typeof function` + 对上游源码 `toContain`,**三条一起也拦不住**一个
「真调 factory(日志因此出现)、丢掉返回值、硬编码同形状 hooks」的 wrapper。改成:
①期望键集**直接调同一个 upstream factory 派生**;②合成 upstream 返回**模块级 sentinel**,
断言 wrapper 交出来的**就是同一个对象**(identity),两个实参也原样转发;③删掉源码文本断言,
ABI 改由 `managed-plugin-wrapper.ts` 对 `@opencode-ai/plugin` 的 `import type` 做类型级约束。
另加一条**负向控制**:手写那个「丢弃返回值」的 wrapper,证明只有 identity 判得出它。
M3 四清零的观察器把「清空」和「毁掉」折叠成同一个结果(`package-plugin.wiring.cases.ts`)
三个 helper 的 `catch` 分别返回 `[]` / `null` / `{}` ⇒ 删掉或损坏 `alpha.jsonc`、让 grants
读不出、把整本 `installs.json` 重置,四条断言仍全绿。改成:config/ledger **严格读**(读不出
就是失败)、grants **明确断言文件不存在**,并预置**无关 sentinel**(一个 config 值 + 一条
ledger 记录)断言卸载后逐字仍在 —— 那条 sentinel 是杀掉「整文件推倒重写也算通过」的关键。
Refs #699
R1 三条 MAJOR 全部修完 ——
|
| 生产被改成 | 红在哪 | 原始输出 |
|---|---|---|
把整份 alpha.jsonc 推倒重写成 {} |
pluginArrayStrict() |
error: alpha.jsonc "plugin" is undefined, expected an array |
把 grants 文件 chmod 000(而不是删掉) |
existsSync(grantPath(...)) |
Expected: false / Received: true |
把整本 installs.json 重置成 {"v":3,"records":[]} |
sentinel | expect(received).toHaveLength(expected) Expected length: 1 / Received length: 0 |
最后一条是本条 finding 的关键证明。 同一个「重置整本账本」的错误实现,配上 R1 之前的
观察器(catch → {}、无 sentinel)跑同一条用例:
1 pass
7 filtered out
0 fail
Ran 1 test across 1 file
—— 全绿。四条清零断言(packageGraphs ?? [] 为空、plugin 记录为空、plugin claim 为空)
在整本账本被毁掉时逐条成立。只有那条无关 sentinel 分得开「正确清空」与「推倒重写」。
OPTIONAL 的处置
顺带修的手工账本 preview-key 冲突(ext-package-lifecycle.ts 的 packageChildPreviewKeyV1)
按编排者裁决保留、不拆票。范围登记:它把 command/bundle/cloud 这类 package 通道装不出来的
kind 的预览行 key 从 mcp--<name>(与一条真实 MCP child 的授权账 key 逐字相撞,会把别人的
capability 显示到那一行)改成结构上不可能相撞的 unroutable-child:<kind>:<name>。
方向保守、只影响预览面、不作门控。
本轮没做的事
只修这三条:没有重构卸载流程、没有加通用抽象、没有动 renderer / alpha-config-injection.ts /
installPluginFromCas / alpha-web / 上游包(north-star 绿)。
gate-files.tsv 只更新了两条闸门的保证描述与子进程条数(7→8、6→8),下界未放松。
🤖 Generated with Claude Code
Fixes #809
Refs #699
Base 是 T1 的分支
feat/807-opencode-plugin-profile(不是alpha)—— T1 提供本票要用的opencode-pluginprofile 与两个 host capability,尚未合并。大白话
宿主端真正把一个 managed OpenCode Plugin 装进去、记上账、卸得掉。
package-admission.ts:506那个else → "mcp"的兜底三元换成一张单点表
packageChildKindV1,与packageChildTxKeyV1共用同一张表;builder 分派改成对PackageChildKindV1穷举的 switch(没有 else);两处as "skill"|"agent"|"mcp"强转删掉。之前的后果不是「少认一个 profile」是认错 —— 一个只在合同侧登记的 profile 会被静默当成
MCP 装进
alpha.jsonc的mcp段,而卸载侧 fail-closed ⇒ 装得上、卸不掉。main/managed-plugin-wrapper.ts:顶层只有一个id常量和一个函数声明,
import("./upstream.js")写在server()函数体内 ⇒ 引擎里第二个加载器(
ConfigExternalPlugin,ABI 是{id,effect}/{id,setup},解码失败被Effect.ignoreCause静默吞)只求值到一个零副作用空壳,第三方字节从头到尾没被 V2 求值过。没有去动
alpha-config-injection.ts(全局剥plugin键会关掉用户所有合法 V2 插件)。buildPluginTxItems。 两条 file item(plugin--<name>--f0/f1)+ 一条写整个plugin数组的 config item,进同一次事务。不复用、不改
installPluginFromCas(它是 legacy单装载体、自己开一次事务);复用的是
seedPluginPayloadItems的 item 形状与extensionHealthProbeRouter。removePackageChildArtifactsV1新增 plugin 分支:落点是内容寻址的,名字算不出目录,唯一真源是账本 record 的
plugin-path:configKey(与uninstallByKey的 plugin 臂读同一个字段、跑同一道圈禁判据);越出
<root>/plugins/<name>[@…]/plugin.js即拒。PackageArtifactInstallersV1因此加了removePluginPath(与PlannerInstallers同名同签名)。退出条件与每一条闸的绕过实施记录
每条都是在自己的 worktree 里改坏生产代码 → 跑 → 看红 →
git checkout HEAD --还原 →git status干净。实验前工作树已提交(clean)。① 第 7 类 A+B(两条绕过)
opencode-plugin错映成skillpackage-child-kind.test.ts「每一个已登记 profile 都路由到它自己的 kind」1 fail(- "opencode-plugin": "plugin"/+ "opencode-plugin": "skill")else → mcp兜底加回packageChildKindV12 failPROFILE_REGISTRY_V1的 profileId 集」+ 逐 profile 路由2 fail(opencode-tui-pluginExpected true / Received false)期望键集从
PROFILE_REGISTRY_V1派生,不写字面表;逐 profile 的 kind 值是字面量(那正是 A 要钉的东西)。「各自路由到不同的具名 builder」的行为半场在
package-plugin.wiring.cases.ts:一次真安装的计划里同时出现四个 builder 的特征 item(skill 的
files:["SKILL.md"]/ agent 的agents/<n>.md+agent.<n>/mcp 的
["mcp",<n>]/ plugin 的两条 file +["plugin"])。② 第 8 类四条一起(四条绕过)
fs.rmSync(ledgerDir):398① 目录(Expected false / Received true)installers.removePluginPath(...):400②plugin[]removeInstallGrants([plugin--<name>]):402③ grants(读回ext-store/plugin--opencode-notify/grants.json仍在)applyPackageMutation:405④ 账本记录(- Expected 1 / + Received 44)第 ④ 条走生产卸载后重读账本;没有断言中间值
graphAfter === null。③ file item key(一条绕过)
plugin--<name>--f<i>→pluginfile--<name>--<i>⇒ 整次安装在 pre-switch 失败:tx-aborted {"stage":"pre-switch-probe","reason":"health probe failed for \"pluginfile--opencode-notify--0\": no typed probe for file item … — refusing (fail closed)"},7 条里 6 条红。④ 第 3 类(一条绕过)
把 config item 的值改成裸包名
opencode-notify@0.3.1⇒- ".../plugins/opencode-notify@d3c9d917.../plugin.js"/+ "opencode-notify@0.3.1"红。断言本体 = 经生产 install 真写盘后读回真实
alpha.jsonc:精确 managed 绝对路径 + 目录圈禁谓词(
isAbsolute/.js/underAlphaPlugins)+ 无同名 legacy/npm 条目;并把「每一条发出去的 URL 都必须是签名信封里的那几条」写成白名单(不写「没调用
Npm.add」,那会被第二个加载器绕过)。⑤ wrapper(两条绕过)
effect: () => {}defaultOwnKeys+ "effect"—— 断言对准mod.default,不是 named exportsimport("./upstream.js")挪回模块顶层~/.opencode-notify.log出现了)⑥ D3
server()返回值(两条绕过)+ "unusedEvent"expect(typeof value, name).toBe("function")→event: Expected "function" / Received "number"第 6 类的核实义务(开工时实读,结论:基线那条裁决成立)
「未登记 profile 的 secret/connection 投影返回空集是当前正确行为,因为严格 payload decoder 才是
profile 可达性的咽喉」—— 确认没有生产路径绕过
supportComponent()。三条独立轴:① 符号
PackageSupportedComponentV1全仓搜,类型只有一个构造点narrowComponent;②
narrowComponent全仓搜,只有一个调用点decoder.ts:394,就在:391supportComponent()命中的
if (support.ok)分支里;③
supportComponent全仓搜,定义 + 唯一调用点两处。唯一的第二个构造点是
package-admission.test.ts:790的as unknown as测试强转。⇒ 裁决成立,没有去改那两处生产 else;按边界各加了一条 plugin 具名用例
(空集是「对 plugin 这个 profile 的正确答案」,不是「没写分支所以走兜底」),
外加一条 schema 半场:plugin payload 里塞
requiredSecrets⇒ 解码期即拒。本地门(真实输出)
bash scripts/alpha-check.sh→ exit 1,红的只有 base 就红的那一条(两行同一条):(fail) alpha-web extension package producer artifact pin > the vendored host contract copy is byte-identical to this repository's live host artifact—— 那是 T4 的反向 pin,#811会修。其余
✓ zero upstream package edits/✓ no literal NUL bytes/✓ typecheck/✓ seed assets/✓ docs links全绿。bun-test-floor.sh 3000 packages/ui-mac src→ 3783 pass / 0 fail / 260 files(base = 3769 / 0 / 257)。
bun-test-floor.sh 100 packages/ext→ 132 pass / 0 fail / 10 files(与 base 逐字相同)。package-child-kind.test.ts、package-plugin.wiring.test.ts、managed-plugin-wrapper.test.ts),并把package-alpha-connection54→55、package-secret-prerequisite18→20、ext-package-lifecycle-permutations19→21 的下界跟到实际条数(不留余量)。push --no-verify:pre-push 跑的就是alpha-check.sh,而它因为上面那条 T4 反向 pin 在本分支上必然 exit 1(base 亦然),不是本 PR 引入的。
主动没做的事(逐条)
main/alpha-config-injection.ts(D4 之后不再需要;全局剥plugin键会关掉用户所有合法 V2 插件)。installPluginFromCas;CAS pin/unpin 保持零生产调用(D5 已删)。packages/opencode(north-star 绿)。fetchPackageAsset的 timeout、终态 URL 的 HTTPS/userinfo 复查)动一行。buildPluginTxItems对「同名派生落点已在场且不是本次目标」具名拒绝(复用 seed 通道的
findSameNamePluginPathEntry,只把它加了个export),这是保守方向而不是新功能。
报告里另列的两条连锁(不是本 PR 修的)
实测:
{"ok":false,"code":"curation-unverifiable","reason":"plugin opencode-notify: cannot verify curation — the entry is not resolvable from the verified catalog …"}。根因是setInstallStateByKey的 curation 闸按record.id去 legacycatalog.entries里找条目,而签名 package 的 child 从来不在那张表里。对 skill/agent/mcp 它「只是」开不了(config 叶已经带着
禁用标记写进去了);对 plugin 它是致命的 —— plugin 的唯一启用面就是出现在
plugin[]里。本 PR 把它如实钉成一条会红的用例(哪天修好了,那条用例红,而红的时候正是该删掉它的时候)。
desiredState: "disabled"(admission 只传{origin:"catalog"},没有 source / 没有 curation ⇒initialDesiredState走「catalog 且 source !== "alpha"」那一格),而
installedDisabled只在 root 是 MCP 时才报给renderer。⇒ 首装之后
plugin[]是空的(正确行为,不是漏写)。因此退出条件④ 分成两半:一半断言首装没有写任何东西进
plugin[],另一半把「用户已经把它开着」这个 durable intent 用生产账本写器
setDesiredStateV2落进账本再跑一次真安装,断言生产那条 config item 写进去的是精确的 managed 绝对路径。被测对象(config item 的值)全程没有被注入过。
🤖 Generated with Claude Code