Skip to content

[REQ-128][CODE] renderer 半场:分流 + 预览屏 + 已装扩展包区块(含启用开关)+ 移除 + 热重载接线 - #791

Merged
jinjunnn merged 6 commits into
alphafrom
feat/784-t4-renderer
Aug 2, 2026
Merged

[REQ-128][CODE] renderer 半场:分流 + 预览屏 + 已装扩展包区块(含启用开关)+ 移除 + 热重载接线#791
jinjunnn merged 6 commits into
alphafrom
feat/784-t4-renderer

Conversation

@jinjunnn

@jinjunnn jinjunnn commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Fixes #784
Refs #653

大白话

在这条 PR 之前,main 侧三票(#780/#781/#782)已经全部铺好,而用户点不动任何东西
preload 一条新通道都没暴露、importSkillFolder 的「这是插件目录」判别臂在 use-extensions
里被折成一句「导入失败」、confirm 端口还是 fail-closed 占位、已装的扩展包在已安装页结构上
看不见。这一票补的就是用户真正看得见摸得着的那半场。

用户路径:点「导入 → 文件夹」挑一个 Claude 插件目录 → 弹一屏预览,逐条说清装什么、不装什么
以及为什么 → 确认 → 已安装页多出一张扩展包卡,里面的技能默认全关,卡上一行字说明「打开
开关才会生效」→ 打开任意一个,引擎当场重载,下一条消息里就能用 → 「移除此扩展包」一键连
文件带记录一起拿掉,引擎同样当场重载。

这一票为什么按「整条竖线」验,而不是按层验

Phase 1 漏掉 renderer 半场 ⇒ 全部票交付、全部过审计,最后才发现用户点不动;
Phase 2 漏掉三条跨仓竖线 ⇒ 同一形态高一层。按层拆票时,断掉的那一截不在任何一张票的边界内
于是每一张票都能诚实地绿。

所以本票的主闸是一个把九跳跑完的端到端用例文件
test-component/local-package-renderer.cases.ts,15 条),里面只有一样东西是替身
引擎的 HTTP 客户端(POST /global/dispose 在无头进程里没有对端)——那是进程边界,
而它恰好就是 G20 的断言对象。其余全是生产代码:真 ExtensionHub/ExtensionDetail
registerExtIpcHandlers、真 claude-plugin-intake、真 collectImportSkillPayload
local-package-preview、真 claude-plugin-install、真 runExtensionTransaction、真 V3 账本。

第 9 跳「下一条消息里技能真可用」的判据 = skills-enabled.json——
引擎注入门(packages/ext/src/gen-skill-paths.ts)实际读的那个文件,不是「我查了账本」。

改了什么

  • 分流(第 1 跳):runImportFolder 按 main 给的 route 走。renderer 不判目录形态、
    也拿不到路径
    #255)。非插件目录逐字不变,回归用例钉死。
  • 预览屏(第 3 跳):装 / 不装 / 五类人话原因 / 不支持的组件类型 / 不支持的布局逐条呈现;
    原因文案全部来自 main 的 reasonCode → 人话 表,renderer 一个字都不自己编、不折叠。
    重复导入、超预算、一个都装不上三种终态各有具名呈现;无可装项时确认键保留但禁用
    原因写在键面上。
  • confirm 接线(第 4 跳):local-package-install-port 转调 installLocalClaudePluginV1
    喂的是 preview 期已经留在 main 的字节。刻意collectLocalPackagePayloadsV1
    重新采集——那会让 G19 从一道闸变成一段写着「闸」的注释,并重新引入 K15 明令否决的
    confirm 期重扫(第二个 TOCTOU 窗口 + 第二份摘要文法)。
  • 已装扩展包区块(第 6/7 跳):每个技能一个开关(裁决 B 之下列表里没有开关 = 用户装完
    拿不到任何东西),包头挂整包移除;开关走既有 runStateToggle,与平铺行同一条通道、
    同一个账本、同一次重载——不另造第二套「包内开关」。
  • 第 8 跳:包内单个技能被单独移除 ⇒ 行上明确拒绝 + 「移除整个扩展包」的出路。
    判据是「main 拒了 这一条在某个包图里」,不解析 reason 的文字
  • G20 热重载:confirm 成功与整包卸载成功都走既有 refreshEngine(),失败如实呈现
    「待重载」。两条都收口在 use-extensions 一层——逐调用点接一行的形态在本仓已栽三次
    #765 是同一个故事)。详情页的整包移除也改走这一层。

每道闸都实施过一次绕过

12 条配方与结果全在 docs/verification/2026-08-02-req128-t4-gate-bypass-experiments.md

G20 的基线记录(票面 AC2 要求):把生产代码改回本票之前的原样
(整包移除只 refetchInstalled() 不 refresh / 详情页直连 IPC),两条闸当场变红——
也就是说 G20 钉的是一条这一票才接上的线,不是一段已经成立的行为。

记录里还诚实收了两条第一轮没有变红的配方,并写清它们为什么不算闸失效:
判据不是「我改了一行」,是「用户可观察的行为真的变了吗」。

本地门(真实输出)

▶ [1/4] north-star guard (zero upstream edits)
    ✓ zero upstream package edits
▶ [2/4] no literal NUL bytes in version-controlled files
    ✓ no literal NUL bytes
▶ [3/4] typecheck (alpha packages: contracts-consumer + ext + ui-mac)
    ✓ typecheck
▶ [4/4] contract lock + unit tests (contracts-consumer + ext + ui-mac)
    ✓ tests
✅ all local gates green

ui-mac3747 pass / 0 fail / 254 files
base(feat/782-t3-preview-channel @ e4004efa,同机新建 worktree 实测):
3746 pass / 0 fail / 253 filesbase fail-set = 空集,本 PR fail-set = 空集
差 = +1 个测试文件 / +1 条用例(本票的九跳 runner)。

设计稿:按基线改了三处(稿未批,AC 级行为以已批基线为准)

  1. 包卡上的名字用 rootComponentName(root 组件名),不是插件名。 稿里画的是 tide
    账本里没有「包显示名」这个事实——造一个不诚实;从 local:tide 反推等于从 id 里读结构
    #737 明令禁止)。代价:用户导入 tide,卡上看到的是 postmarket-review
    这是一个真实的产品瑕疵,需要 owner 裁决要不要给 V3 图加一个显示名字段(那是 schema 改动,
    不在本票范围)。
  2. 「取消」在安装进行中照样可点(稿里的「移除中…」是禁用的)。理由:main 会如实拒绝
    install-in-flight,用户必须有一条出路才能读到那句真话;禁掉按钮等于把真话藏起来,
    用户只会以为界面卡死。拒绝写在屏内、弹窗留在原地——绝不先关弹窗再飞一条 toast
    (那样用户读到的第一件事就是「它取消了」)。
  3. 稿的 Q2(区块列不列目录来的包)取 A:账本里有什么就列什么,用「来自本地文件夹 /
    来自官方目录」两枚标区分,来源从 child record 的 origin 读,不从 packageId 前缀读。

稿的 Q1(包内技能照常出现在平铺列表 + 一枚「属于扩展包 X」标)、Q3(确认键保留但禁用)、
Q5(确认成功后自动切到已安装页)按稿实现——Q5 尤其对着裁决 B:不跳页的结果就是用户按完确认
什么都看不到,然后认为功能坏了。

顺带修好的一个既有缺陷

从目录装的扩展包,移除之后那些技能其实一直能用到下次重启
extension-detail.tsxremovePackagerefetchInstalled()、不 refreshEngine())。
本票把它收进同一条通道,一并修好。

主动没做的事(超出票范围,记下来不顺手改)

  • 不重画定制中心 IA:导入页仍是 5 张卡,不新增第六张;货架、浏览卡、详情页段顺序、
    已安装页的 scope 分组一寸未动。
  • 不做 T5(#783)的验证矩阵与真机 L2:本票只做到无头进程里可判的最强形式
    skills-enabled.json)。真机「下一条消息里技能真被引擎注入」归 T5。
  • 不动 docs/design/current/customization-center/components.md 的落地列:那份台账在
    设计稿分支(PR [#784][DESIGN] 本地插件包导入并入活稿:三块帧按上线实况画 + 台账落地列回填 #785)上、稿未批,回填是 design-loop 批准后并入活稿那一步的事。
  • 不给 V3 图加「包显示名」字段:见上面第 1 条,是 schema 改动 + owner 裁决。
  • 没有为 local: 包做「更新」:重导入前必须先整包移除(K19),预览屏在确认之前说清。

🤖 Generated with Claude Code

@jinjunnn
jinjunnn force-pushed the feat/784-t4-renderer branch from ba6856e to b17b46f Compare August 2, 2026 12:39
@jinjunnn
jinjunnn changed the base branch from feat/782-t3-preview-channel to alpha August 2, 2026 12:43
@jinjunnn

jinjunnn commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

R1 五条 + owner 的显示名裁决,全部落地

base 已从 feat/782-t3-preview-channel 改为 alpha(T1/T2/T3 均已合入,分支已 rebase 到 ca7e3968)。

owner 裁决:包显示名

PackageGraphV1可选 displayName,安装时存 .claude-plugin/plugin.json 自己声明的 name

  • 可选,不是必填:存量图缺字段 ⇒ 呈现层回退 rootComponentName,绝不因为缺字段就拒载
  • 来源判定一个字没动:「是不是策展来的」仍然只有 child record 的 origin 一个答案。
    新字段只管显示,由行为闸钉住(显示名写成 official-catalog-package,来源标仍是「来自本地文件夹」),
    外加一条源码枚举减速带(已在测试里标注它是减速带,不是安全边界)。
  • 摘要口径在场才计入,存量图的 canonical JSON 逐字节不变。

⚠️ 开发中这里真实红过一次:decoder 校验了 displayName 却没把它带进解码结果 ⇒ 重算摘要少一项
一张合法的、带显示名的图被篡改闸判成「被改过」而拒载。这正是「前提为假的闸门比没有闸门更贵」
的最小实例——它拒的不是坏输入,是我们自己刚写的配置。

Codex R1 五条(全部采纳,无一超纲)

修法
[Blocker] 第 9 跳只读文件、没跑引擎的读 改成执行生产 AlphaExtconfig hook,断言 cfg.skills.paths 里只出现已开启技能的 live generation 目录;整包移除后归零。这条链包含允许集版本闸、SAFE_KEYcurrent.json 指针与目录存在性——全部真跑
[Major] SDK 的 HTTP 错误被当成 dispose 成功 refreshEngine 改为复用既有connectOutcome(同一个问题在本文件里已经有答案,不写第二个);夹具补上「resolve 成 {error, response:{status:503}}」这条真实形状——旧夹具只会 throw。
[Major] !result.ok 那条解释不成立 你判得对。补一条非插件目录导入失败的用例(坏 frontmatter ⇒ main 回 {ok:false} 且不带 route),断言保持原行内错误、不进预览。变异现在杀得掉了。
[Major] 冷启动账本损坏同时显示「读不出」和「尚未安装」 package 投影读不出时抑制通用空态;用例排在全文件第一条——那时 receipts 才真的是空的,放在安装用例之后杀不掉这条。
[Major] retained 解释随包卡一起消失 提到区块级结果上;用两个共享同一 child 的包(生产写器 applyPackageMutation 种的)断言:卡消失后原因仍在,且与账本事实一致。

顺带修掉一条夹具假绿

waitFor 没有 await 断言 ⇒ 传 async 断言进去时它的拒绝变成 unhandled rejection、
waitFor 立刻返回成功。第 9 跳换成异步的引擎 hook 之后正好踩上——那一条一度是永远绿的。

绕过实验(第二轮,12 条)

全部实际跑过,记录在
docs/verification/2026-08-02-req128-t4-gate-bypass-experiments.md
11 条当场变红;D4 第一版配方没变红——真因是 canonicalJson 本来就丢弃 undefined 键,
那个条件展开只是冗余防御。处置不是删断言,是把闸挪到前提本身,并把配方换成「给缺席值填占位」,
它当场变红。这是同一形态第三次:绕过配方本身也会是假的

本地门(真实输出)

▶ [1/4] north-star guard    ✓ zero upstream package edits
▶ [2/4] NUL bytes            ✓ no literal NUL bytes
▶ [3/4] typecheck            ✓ typecheck
▶ [4/4] contract lock + unit ✓ tests
✅ all local gates green

ui-mac 3754 pass / 0 fail / 254 files(rebase 后 base = 3747/0)。

🤖 Generated with Claude Code

@jinjunnn

jinjunnn commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

R2 Major 闭合:显示名不再能让安装失败

先复现,不推断。修复前实测:

[ext-transaction] tx-rolled-back {"stage":"receipt-commit",
  "reason":"receipt commit failed: package ledger mutation rejected (fail closed):
            packageGraph.displayName: must be 1..128 characters"}

129 个 a 的插件名照样进 installable 预览(mintPackageId 把它截成合法的 128 字符 ID),
安装器写的是原始 129 字符,decoder 在 receipt commit 那一刻拒绝 ⇒ 整个事务回滚。
含控制字符的名字同理。一个只管显示、而且明确可选的字段,反向破坏了安装路径。

两条约束逐条落实

  1. 唯一真源:isValidPackageDisplayName 出在 ext-package-ledger-v3.ts,
    decoder 与 intake 都消费它。此前 intake 一份(只要求非空)、decoder 一份(长度+控制字符),
    两份不一致就是这条缺陷的全部起因。
  2. 判定发生在预览期:LocalPackagePreviewV1 新增 displayName / displayNameNotice,
    intake 定案,安装器只透传、不再从 preview.name 派生(派生就是第二份文法)。
    名字存不下 ⇒ 预览期具名告知「装好后列表里会用它第一个技能的名字」,安装照常成功,
    账本里那张图根本没有 displayName 字段(缺席合法,不是存了个截断值)。

没有选截断 —— 截断会把作者写的名字改写成我们编的名字,而这个字段本来就是可选的:
最小且诚实的处置是「存不下就不存,并在确认之前说清它会怎么显示」。
安装器另留一条事务前的具名拒绝兜底:万一有人绕过 intake 直接构造 preview,
失败也发生在动任何东西之前,而不是 commit 期回滚。

三道闸的绕过实际输出

绿基线 22/0(renderer 竖线)+ 52/0(真实语料 intake):

改坏了什么 结果
超长名字预览期定案 去掉 intake 的判定(= 修复前) 3 fail
控制字符那一臂单独覆盖 只放宽控制字符臂,长度臂保留 2 fail
真实语料零误伤 判据收到 26 字(< 实测最长 27) 1 fail
长度帽取值依据 帽收到 20 1 fail
安装器不许自己派生 改回 displayName: preview.name 3 fail
预览期告知不许省 去掉 notice 3 fail

真实语料零误伤(反向检查):62 个插件全部拿得到显示名、没有一条告知、
显示名与 manifest 原样相等,可装总数仍是 132

一件我自己的错,连带修掉

「真实语料零误伤」这条闸的第一版绕过配方(收到 30 字)没有变红
我以为 30 会误伤,依据是我在注释里写的「本机语料最长 31」—— 实测是 27
(claude-for-msft-365-install)。那是一句没跑就写下的散文断言,而它当时还被我用来
论证「128 够长」。

处置:注释改成实测值并写明是实测;把这个数钉进测试(最长 27 且帽 > 27);
配方换成 26 与 20,两条当场变红。

前三次没变红是配方改错了地方;这一次不同 —— 配方是照着一句假事实设计的

本地门(真实输出)

▶ [1/4] north-star guard    ✓ zero upstream package edits
▶ [2/4] NUL bytes            ✓ no literal NUL bytes
▶ [3/4] typecheck            ✓ typecheck
▶ [4/4] contract lock + unit ✓ tests
✅ all local gates green

ui-mac 3755 pass / 0 fail / 254 files

复现脚本已删,场景由用例常驻覆盖:夹具 long-name-dir(manifest name = 129 个 a)与
control-name-dir(name 含 String.fromCharCode(1),代码现造、不写转义字面量,NUL 闸绿)。

🤖 Generated with Claude Code

jinjunnn added 6 commits August 2, 2026 09:34
第 1→9 跳里 renderer 拥有的那一半。在这一提交之前,main 侧全部铺好而**用户点不动**:
preload 一条新通道都没暴露,`importSkillFolder` 的插件目录判别臂在 `use-extensions` 里被
折成一句「导入失败」,confirm 端口是 fail-closed 占位,已装的包在已安装页结构上看不见。

- 分流:`runImportFolder` 按 main 给的 `route` 走(renderer 不判目录形态、不传路径,`#255`)。
  非插件目录逐字不变,回归用例钉死。
- 预览屏:逐条呈现装/不装/五类人话原因/不支持的组件类型/不支持的布局;重复导入、
  超预算、一个都装不上三种终态各有具名呈现;确认键在无可装项时保留但禁用。
- 已装扩展包区块:每个技能一个开关(裁决 B 之下不给开关 = 用户装完拿不到任何东西),
  包头挂整包移除;「账本读不出来」与「没装」永不折叠。
- 第 8 跳:包内单个技能被单独移除 ⇒ 行上明确拒绝 + 指向整包移除,不是假成功。
- G20 热重载接线:confirm 成功与整包卸载成功都走**既有** `refreshEngine()`,失败呈现待重载。
  两条都收口在 `use-extensions` 一层 —— 逐调用点接一行的形态在本仓已栽三次。
- confirm 端口接到 `installLocalClaudePluginV1`,喂的是 **preview 期留下的字节**
  (不调 `collectLocalPackagePayloadsV1` 重采集:那会让 G19 变成一段写着闸的注释,
  并重新引入 K15 否决的 confirm 期重扫)。

Fixes #784
绕过记录里诚实收了两条**第一轮没有变红**的配方,并写清它们为什么不算闸失效:
判据不是「我改了一行」,是「用户可观察的行为真的变了吗」。

Refs #784
owner 裁决:`PackageGraphV1` 加**可选** `displayName`,安装时存 `plugin.json` 自己声明的
`name`。存量图缺字段 ⇒ 回退 root 组件名,**绝不因为缺字段就拒载**。来源判定不变:
「是不是策展来的」仍然只有 child record 的 `origin` 一个答案,新字段只管显示。
摘要口径**在场才计入** —— 无字段图的 canonical JSON 与加字段之前逐字节相同,存量图的
digest 照旧过篡改闸(开发中真实红过一次:decoder 校验了却没把字段带出去 ⇒ 合法图被拒载)。

Codex R1 五条全部采纳:

- **[Blocker] 第 9 跳只读了允许集文件,没跑引擎的读。** 改成执行**生产 `AlphaExt` 的 config
  hook**,断言 `cfg.skills.paths` 里只出现已开启技能的 live generation 目录;移除后归零。
  读「引擎会读的那个文件」≠ 跑「引擎的读」。
- **[Major] SDK 的 HTTP 错误被当成 dispose 成功。** v2 client 默认 `throwOnError:false`,
  503 会正常 resolve 成 `{error}`。`refreshEngine` 改为复用**既有**的 `connectOutcome`;
  夹具补上「resolve 成 {error}」这条真实形状(旧夹具只会 throw)。
- **[Major] 非插件目录只覆盖了成功路径。** 补一条**导入失败**的非插件目录用例:
  保持原行内错误、不进插件预览。`isLocalPluginRoute` 写成 `!result.ok` 于是杀得掉了。
- **[Major] 冷启动账本损坏会同时显示「读不出」和「尚未安装」。** 通用空态在 package
  投影读不出时抑制;用例排在全文件**第一条**,那时 receipts 才真的是空的。
- **[Major] 整包移除后「留下了什么」随包卡一起消失。** retained 提到区块级结果上,
  用两个共享同一 child 的包断言:卡消失后原因仍在。

顺带修掉一条**夹具假绿**:`waitFor` 不 await 断言 ⇒ 传 async 断言进去时它永远立刻成功。

Refs #784
D4 的第一版配方(条件展开改无条件)**没有变红** —— 真因是 `canonicalJson` 本来就丢弃
`undefined` 键,那个条件展开只是冗余防御。处置不是删断言,是把闸挪到前提本身
(`canonicalJson({a,b:undefined})` 必须等于 `canonicalJson({a})`),并把绕过配方换成
「给缺席值填占位」—— 那才是会让全部存量图集体拒载的改法,它当场变红。

这是同一形态第三次:**绕过配方本身也会是假的**。

Refs #784
复现(修复前,实测):插件名 129 个 `a` ⇒ `readManifest` 只要求非空、`mintPackageId` 把它
截成合法的 128 字符 ID ⇒ **照样进 installable 预览**;安装器把**原始 129 字符**写进
`displayName` ⇒ decoder 在 **receipt commit 那一刻**拒绝 ⇒ `tx-rolled-back`。
含控制字符的名字同理。一个**只管显示、而且明确可选**的字段,反向破坏了安装路径。

两条约束逐条落实:

- **唯一真源**:`isValidPackageDisplayName` 出在 `ext-package-ledger-v3.ts`,
  decoder 与 intake **都消费它**,谁都不再抄一份。此前 intake 一份(只要求非空)、
  decoder 一份(长度 + 控制字符),两份不一致就是这条缺陷的全部起因。
- **判定发生在预览期**:`LocalPackagePreviewV1` 新增 `displayName` / `displayNameNotice`,
  intake 定案,安装器**只透传**、不再从 `preview.name` 派生(派生就是第二份文法)。
  名字存不下 ⇒ 预览期具名告知「装好后列表里会用它第一个技能的名字」,**安装照常成功**,
  账本里那张图**根本没有** displayName 字段(缺席合法,不是存了个截断值)。
  不偷偷截断:截断会把作者写的名字改写成我们编的名字。
- 安装器另加一条**事务前**的具名拒绝作兜底:万一有人绕过 intake 直接构造 preview,
  失败也发生在动任何东西之前,而不是 commit 期回滚。

反向检查(「前提为假的闸门」的另一面):真实语料 62 个插件**零误伤** ——
每个都拿得到显示名、没有一条告知、显示名与 manifest 原样相等,可装总数仍是 132。

Refs #784
M3 的第一版配方(判据收到 30 字)**没有变红** —— 而这一次不是配方改错了地方,是
**配方照着一句假事实设计的**:我在注释里写「本机语料最长 31」,实测是 **27**
(`claude-for-msft-365-install`)。那句话当时还被我用来论证「128 够长」。

处置:注释改成实测值并写明是实测;把这个数**钉进测试**(最长 27 且帽 > 27);
绕过配方换成 26 与 20,两条当场变红。

四次同一形态,前三次是配方错、这一次是**前提假**。能执行就别推断。

Refs #784
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] renderer 半场:分流 + 预览屏 + 已装扩展包区块(含启用开关)+ 移除 + 热重载接线

1 participant