Skip to content

[#809][CODE] plugin 事务与生命周期:kind 分流收口 + wrapper 模板 + buildPluginTxItems + 卸载臂 - #816

Merged
jinjunnn merged 2 commits into
alphafrom
feat/809-plugin-transaction
Aug 3, 2026
Merged

[#809][CODE] plugin 事务与生命周期:kind 分流收口 + wrapper 模板 + buildPluginTxItems + 卸载臂#816
jinjunnn merged 2 commits into
alphafrom
feat/809-plugin-transaction

Conversation

@jinjunnn

@jinjunnn jinjunnn commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Fixes #809
Refs #699

Base 是 T1 的分支 feat/807-opencode-plugin-profile(不是 alpha)—— T1 提供本票要用的
opencode-plugin profile 与两个 host capability,尚未合并。

大白话

宿主端真正把一个 managed OpenCode Plugin 装进去、记上账、卸得掉。

  1. kind 分流收口。 package-admission.ts:506 那个 else → "mcp" 的兜底三元换成一张
    单点表 packageChildKindV1,与 packageChildTxKeyV1 共用同一张表;builder 分派改成对
    PackageChildKindV1 穷举的 switch(没有 else);两处 as "skill"|"agent"|"mcp" 强转删掉。
    之前的后果不是「少认一个 profile」是认错 —— 一个只在合同侧登记的 profile 会被静默当成
    MCP 装进 alpha.jsoncmcp 段,而卸载侧 fail-closed ⇒ 装得上、卸不掉。
  2. wrapper 模板。 新建 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 插件)。
  3. buildPluginTxItems 两条 file item(plugin--<name>--f0/f1)+ 一条写整个 plugin
    数组的 config item,进同一次事务。不复用、不改 installPluginFromCas(它是 legacy
    单装载体、自己开一次事务);复用的是 seedPluginPayloadItems 的 item 形状与
    extensionHealthProbeRouter
  4. 卸载臂。 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 错映成 skill package-child-kind.test.ts 「每一个已登记 profile 都路由到它自己的 kind」 1 fail(- "opencode-plugin": "plugin" / + "opencode-plugin": "skill")
else → mcp 兜底加回 packageChildKindV1 「未登记的第六个 profile 被具名拒绝」+「原型链上的名字不算登记」2 fail
往 registry JSON 里加一个表不认识的第六 profile 「表的键集恰等于 PROFILE_REGISTRY_V1 的 profileId 集」+ 逐 profile 路由 2 fail(opencode-tui-plugin Expected 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(...) :400plugin[]
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(两条绕过)

绕过
往 default 里加 effect: () => {} defaultOwnKeys + "effect" —— 断言对准 mod.default,不是 named exports
import("./upstream.js") 挪回模块顶层 「只 import wrapper ⇒ 第三方字节零求值」Expected false / Received true(第三方顶层写的 ~/.opencode-notify.log 出现了)

⑥ D3 server() 返回值(两条绕过)

绕过
canary 多返回一个 hook 键 「返回键恰为三项」+ "unusedEvent"
让某个 hook 值变成非 function(键集不变) expect(typeof value, name).toBe("function")event: Expected "function" / Received "number"

⚠️ 基线更正(实测):已批基线 §4 D3 写的是「返回键恰为两项:event + permission.ask」。
实读是三项 —— vendored 的 plugin.js 还返回 tool.execute.before(:1020;:762
permission.ask,:803event)。照原文写死 2,这道闸第一天就是红的。此处按实测钉三项,
并在测试文件里留了更正理由。三个名字另对 ABI 的 SOT(packages/plugin/src/index.ts)做了文本交叉
核对,不在测试里另抄一份 21 键表。

第 6 类的核实义务(开工时实读,结论:基线那条裁决成立)

「未登记 profile 的 secret/connection 投影返回空集是当前正确行为,因为严格 payload decoder 才是
profile 可达性的咽喉」—— 确认没有生产路径绕过 supportComponent()。三条独立轴:
① 符号 PackageSupportedComponentV1 全仓搜,类型只有一个构造点 narrowComponent;
narrowComponent 全仓搜,只有一个调用点 decoder.ts:394,就在 :391 supportComponent()
命中的 if (support.ok) 分支里;
supportComponent 全仓搜,定义 + 唯一调用点两处。
唯一的第二个构造点是 package-admission.test.ts:790as 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 src3783 pass / 0 fail / 260 files
    (base = 3769 / 0 / 257)。
  • bun-test-floor.sh 100 packages/ext132 pass / 0 fail / 10 files(与 base 逐字相同)。
  • 闸门文件登记簿:79 → 82(新增三条:package-child-kind.test.ts
    package-plugin.wiring.test.tsmanaged-plugin-wrapper.test.ts),并把
    package-alpha-connection 54→55、package-secret-prerequisite 18→20、
    ext-package-lifecycle-permutations 19→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 已删)。
  • 没碰 renderer 一行(那是 T5)、没碰 alpha-web、没碰 packages/opencode(north-star 绿)。
  • 没有给 T2a 的两件事(fetchPackageAsset 的 timeout、终态 URL 的 HTTPS/userinfo 复查)动一行。
  • 没有为 update 路径做 managed plugin 的替换/离场支持:buildPluginTxItems 对「同名派生落点已在场
    且不是本次目标」具名拒绝(复用 seed 通道的 findSameNamePluginPathEntry,只把它加了个 export),
    这是保守方向而不是新功能。
  • 没有改已批基线那份 Markdown(§4 D3 的「两项」是错的,更正理由留在测试注释里,改不改由主 session 定)。

报告里另列的两条连锁(不是本 PR 修的)

  1. 生产启停通道今天对 package child 一律拒,managed plugin 因此无法被用户启用
    实测:{"ok":false,"code":"curation-unverifiable","reason":"plugin opencode-notify: cannot verify curation — the entry is not resolvable from the verified catalog …"}。根因是
    setInstallStateByKey 的 curation 闸按 record.idlegacy catalog.entries 里找条目,
    而签名 package 的 child 从来不在那张表里。对 skill/agent/mcp 它「只是」开不了(config 叶已经带着
    禁用标记写进去了);对 plugin 它是致命的 —— plugin 的唯一启用面就是出现在 plugin[] 里。
    本 PR 把它如实钉成一条会红的用例(哪天修好了,那条用例红,而红的时候正是该删掉它的时候)。
  2. 一切签名 package 的组件今天都落 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

…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
@jinjunnn
jinjunnn force-pushed the feat/809-plugin-transaction branch from 486a4de to 2089838 Compare August 3, 2026 07:55
@jinjunnn
jinjunnn changed the base branch from feat/807-opencode-plugin-profile to alpha August 3, 2026 07:55
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
@jinjunnn

jinjunnn commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

R1 三条 MAJOR 全部修完 —— 9455da5b

bash scripts/alpha-check.shexit 0,7/7 全绿(base 已是 alpha@6126e69e,不再有 T4 反向 pin 的红):

▶ [1/7] north-star guard      ✓ zero upstream package edits
▶ [2/7] no literal NUL bytes  ✓ no literal NUL bytes
▶ [3/7] typecheck             ✓ typecheck
▶ [4/7] contract lock + unit  ✓ tests
▶ [5/7] assert gate files     ✓ gate files   (✓ 84 个闸门文件全部在位且真的跑过)
▶ [6/7] seed assets present   ✓ seed assets
▶ [7/7] docs gate             ✓ no Markdown changed — docs gate is a no-op (与 CI 同)

三套件:contracts-consumer Ran 54 tests across 5 files(0 fail)、
ext Ran 133 tests across 10 files(0 fail)、
ui-mac 3794 pass / 0 fail,Ran 3794 tests across 262 files(上一轮 3783 / 260)。


M1 目录删除失败被谎报成功 —— 已修

ext-package-uninstall.ts 的空 catch 换成整体失败并立刻返回,账本一个字节不动;
reason 只带 errno,不带绝对路径(它会过 IPC 到 renderer)。

正向(新用例,真实磁盘失败:chmodSync(<root>/plugins, 0o500),不 mock rmSync):

 8 pass / 0 fail   Ran 8 tests across 1 file

断言链:failed.ok === false / failed.stage === "artifacts" /
reason 含 install directory could not be removedEACCES / reason 不含绝对路径 /
目录还在 / installs.json 逐字节等于卸载前那份 / 恢复权限后重试 ⇒ 四条清零照常成立。

反向(把空 catch 放回去):

496 |   expect(failed.ok).toBe(false)
                          ^
error: expect(received).toBe(expected)
Expected: false
Received: true
(fail) 目录删不掉时卸载必须失败且账本原样保留,恢复权限后重试收敛
 7 pass / 1 fail

—— 真实磁盘失败下生产返回 ok:true,正是被审出来的那条「谎报成功」。


M2 server() 转发闸把 canary 输出抄成了同一常量 —— 已修

改了三处:①期望键集直接调同一个 upstream.js factory 派生(不写字面量);
②合成 upstream 返回一个模块级 sentinel hooks 对象,断言 wrapper 交出来的就是同一个对象
(toBe,identity 不是形状),并钉住 (input, options) 两个实参原样转发;
删掉packages/plugin/src/index.tstoContain 源码文本断言,ABI 改成类型级约束 ——
main/managed-plugin-wrapper.tsimport type { Plugin, PluginModule } from "@opencode-ai/plugin",
ManagedPluginWrapperModuleV1 = Required<Pick<PluginModule,"id"|"server">>
ManagedPluginUpstreamV1 = { default: Plugin }。上游改 ABI ⇒ ui-mac typecheck 红。
(⇒ @opencode-ai/plugin 这个 workspace 依赖用上了,保留;package.json 里按字母序插入,
bun.lock 只多一行。packages/plugin 不在 north-star 的 UPSTREAM_PATHS 里,引用是允许的。)

反向(把生产生成器改成「真调 factory 让日志出现 → 丢掉返回值 → 按同样的键硬编码空函数」):

187 |   const returned = await mod.default.server(input, options)
188 |   // identity:一个「调了 factory 但丢掉返回值、自己硬编码一张同形状表」的 wrapper 在这里必红。
189 |   expect(returned).toBe(upstreamMod.HOOKS)
                         ^
error: expect(received).toBe(expected)
Expected: { event: [AsyncFunction: event], "permission.ask": [AsyncFunction: permission.ask] }
Received: { event: [AsyncFunction], "permission.ask": [AsyncFunction] }
(fail) server() 交出来的就是 factory 返回的**那个对象**,两个实参也原样转发
 7 pass / 1 fail

这段输出本身就是 finding 的证明:两边键集相同、值都是 async function ——
旧的三条断言(写死键集 / typeof function / 源码 toContain)对它全部通过,
而同一轮里那条 canary 用例(派生键集 + typeof)仍然是 pass 的。只有 toBe 判得出它。

另加了一条常驻负向控制用例:手写那个「丢弃返回值」的 wrapper,断言它
①确实把 factory 调起来了 ②键集一模一样 ③值都是函数 ④not.toBe(HOOKS) ——
把「只有 identity 判得出它」写成一条会一直跑的判据,而不是一次性的实验记录。


M3 四清零的观察器把「清空」和「毁掉」折叠成同一个结果 —— 已修

config/ledger 改成严格读(文件缺失/语法坏/根不是对象/plugin 不是数组 ⇒ 抛,不返回空);
grants 改成明确断言文件不存在;并在卸载前预置无关 sentinel(一个 config 键
alphaTestUnrelatedSetting + 一条 user: 来源的 ledger 记录,后者形状由生产写器
upsertRecordsV2 校验),卸载后断言两件逐字还在
顺带把 integrity 用例的「零变更」从「读出来是空的」改成 alpha.jsonc / installs.json
根本没被创建

反向三次(生产改成「毁掉」而不是「清空」),各自命中不同的断言:

生产被改成 红在哪 原始输出
把整份 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.tspackageChildPreviewKeyV1)
按编排者裁决保留、不拆票。范围登记:它把 command/bundle/cloud 这类 package 通道装不出来的
kind 的预览行 keymcp--<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

@jinjunnn
jinjunnn merged commit 5ef0401 into alpha Aug 3, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[REQ-128][CODE] plugin 事务与生命周期:kind 分流收口 + wrapper 模板 + buildPluginTxItems + 卸载臂

1 participant