[REQ-128][CODE] 本地 Claude 插件读取与安装预览(纯读,零写盘) - #786
Conversation
Fixes #780 Refs jinjunnn/alpha-work#49 用户选一个 Claude 插件目录,主进程只读不写地把它清点一遍:哪些技能能装、 哪些不能装(说人话原因)、哪些组件类型和摆放方式这一版不支持。执行前后磁盘 逐字节不变。 今天的行为是:插件目录一路落到 collectImportSkillPayload,报一句「文件夹内 没有 SKILL.md」—— 与真因毫无关系,用户据它做不出任何正确动作。本票把这句话 换成一份如实的清点。 新增 - main/claude-plugin-intake.ts:布局判定(六类不支持布局具名)、读 plugin.json (version 选填 —— 27/62 真实 manifest 没有它)、枚举 skills/*/SKILL.md、 逐个跑 collectImportSkillPayload + parseSkillFrontmatter + 独立 lstat 自包含 扫描,产 LocalPackagePreviewV1。 - ext-ipc 在 pickImportSkillDir 之后加分流点;非插件目录行为逐字不变。 - test-fixtures/claude-plugin-corpus.json:本机真实语料的仓内夹具(SKILL.md 与 plugin.json 逐字保留,其余文件占位保 size/mode),由 scripts/ 下的生成器导出。 改动(两处最小) - parseSkillFrontmatter 交出**顶层键集**。基线 §14 R2-b:控制字段的判据是 「顶层键在不在」,与它有没有标量值无关;老的 fields 字典要求冒号后有非空标量 ⇒ 块式写法整个不进字典。零新增解析器:不解析块式的值、不引 YAML、不解析嵌套, 顶层性由顶格判定。 - collector 的排除集提升为导出的单一真源 IMPORT_EXCLUDED_DIR_NAMES(基线 §14 R2-a 的首选处置):技能目录内含 node_modules/.git/__pycache__ ⇒ 具名拒绝, 否则就是「预览接受、装完缺件」。 真实语料实测(仓内夹具全量跑) 62 插件 / 162 SKILL.md / 159 在支持布局 / 161 frontmatter 通过 / 25 个 0-skill 插件 / 18 自包含被拒 / 12 控制字段被拒 / 两类重叠 3 ⇒ 并集 27 ⇒ 可装 132。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs #780 Codex R1:1 Blocker + 4 Major + 1 Minor,全部采纳。 Blocker — 闸绕开了真实 IPC handler 新增 test-component/claude-plugin-intake.ipc.cases.ts:跑 ext-ipc.ts 里**已注册**的 ext-import-skill-folder handler(真 electron mock + 真 alpha 环境 + 真 ledgerReady), 对「输入语料树 + 隔离 alpha 根(installs.json)+ userData」三处做前后逐字节指纹。 写面 spy 有盲区:collector 用 namespace import,ESM 命名空间对象不可写 ⇒ 换成可观察结果。 指纹起点取在 ledgerReady 之后 —— 启动期恢复/迁移本就允许写盘,要证的是「这一次导入没写盘」。 子进程隔离:mock.module("electron") 是进程级的,留在 src/** 会连带打红 39 条别处用例。 Major — 预览没消费已装事实 生产分流现在从 v2 账本读已占用技能名、从 V3 图读已装 packageId 并传入 intake; 新增 name-collision-installed / name-collision-in-package 两个原因码; 包内重名两个都跳过(不挑赢家);账本读不出来时如实说「读不出」不折叠成「没装」。 安装期最终裁决仍归 T2 的 G4,本票只负责把事实喂进来并具名呈现。 Major — G16 静默丢 symlink 技能 + scan.failed fail-open 候选枚举改为先枚举条目再判定:symlink 目录 / symlink SKILL.md ⇒ 具名 skill-entry-not-regular, 不再从候选集消失(旧测试断言 skillCandidates===0 是把 bug 写成期望,已改); scan.failed 改 fail-closed(self-containment-scan-failed)—— 扫不动时「没看见」不是「没有」。 Major — G12 杀不掉硬化读取旁路 + plugin.json 裸读 plugin.json 改走与技能载荷同一份 readImportFileBounded(realpath 圈禁 / O_NOFOLLOW / O_NONBLOCK / fstat 帽 / 定长读 / 增长探测)+ 256KB 帽;补三例夹具: 超 256KB 的 SKILL.md(唯一能杀掉「换回裸读」的用例)、超大 manifest、symlink manifest。 Major — G11 假闸形态⑨ 表驱动 1/63/64/65/66/200,所有 >64 均具名整包拒绝 —— 写死 count===65 的实现不再能全绿。 Minor — 真实语料 AC 每次绿灯留 888 个文件 AC1 的共享 corpus 加 afterAll(cleanup)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
绕过实验挖出的自身盲区:只比内容的指纹看不见「反复写同一串字节」—— C2(在生产依赖里写 installs.json)时这条闸没变红,变红的是别的用例。 inode 另外管住「写临时文件 + rename」这种内容相同但确实换过文件的写法。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R1 闭合(1 Blocker + 4 Major + 1 Minor 全部采纳)推到 [Blocker] 闸绕开真实 IPC handler新增
[Major] 预览没消费已装事实生产分流现在自己去读账本: 范围按你的裁决:安装期最终裁决仍归 T2 的 G4, [Major] G16 静默丢 symlink 技能 +
|
| # | 改坏什么 | 结果 |
|---|---|---|
| C1 | 删掉 ext-ipc.ts 的生产分流点 |
RED(子进程 0 pass / 1 fail)—— R1 之前这一条不会红,那正是 Blocker 的定义 |
| C2 | 在生产依赖里偷写 installs.json |
RED(见下方修正) |
| C3 | 生产分流不再传已装事实 | RED(已装同名那条) |
| C4 | 包内重名不再逐组件跳过 | RED ×2(纯函数 + 生产) |
| C5 | symlink 技能回到「枚举期滤掉」 | RED ×2(symlink 目录 + symlink SKILL.md) |
| C6 | scan.failed 回到 fail-open |
RED(深度 33) |
| C7 | 技能载荷换回裸 readFileSync |
RED(超 256KB SKILL.md;其余三例确实杀不掉它) |
| C8 | manifest 回到裸 readFileSync |
RED(超大 manifest) |
| C9 | G11 判据由 > 64 收成 === 65 |
RED ×2(66 / 200;旧的只有 64/65 两点时不会红) |
全部还原后 git status 为空。
C2 顺带挖出我自己这道闸的盲区(已修,单独一次 commit)
C2 第一次跑时,三条 AC9 指纹用例一条都没红 —— 红的是三条 D/K19 用例(靠账本被覆盖间接暴露)。
真因:只比内容的指纹看不见幂等重写。往 installs.json 反复写同一串 {},内容哈希逐次相同,
「逐字节不变」照样成立,而生产其实每次都在写盘。
修法:treeFingerprint 补 mtime + inode(inode 另外管住「写临时文件 + rename」)。
修后重跑 C2:三条 AC9 用例全部变红(4 pass / 6 fail)。
这一条不在审计的 6 条里 —— 是绕过实验自己跑出来的,记在这里。
门(与 base fail-set 的差)
| 门 | 结果 |
|---|---|
| north-star | ✓ 零上游文件改动 |
| 字面 NUL | ✓ 7324 个版本控制文件零命中 |
| typecheck | exit 2,与 base(origin/alpha 13a69d2)逐字节相同;packages/ui-mac 0 条 |
| unit(ui-mac) | 3706 pass / 0 fail(base 3648 / 0;R1 前 3692) |
| contracts-consumer / ext | 132 pass / 0 fail |
我认为你没判错的地方 —— 但有两条要摆给你
plugin.jsonsymlink 那一例的强度被我写小了(上文已标)。要不要再加一条「符号链接指向的目标
合法但 manifest 在读取途中被换掉」的竞态夹具,归你裁 —— 我倾向不加:它要靠readImportFileBounded
自己的 dev/ino 复核,那是#390已有闸的射程,本票再造一份是重复。- 重名判据只覆盖 global scope。生产分流读的是
alphaGlobalRoot(),project scope 的
.alpha/skills没读 —— 因为本路线的 folder 导入生产上恒 global(ext-ipc.ts的 body 里
isProjectTarget(target)分支只经 ecosystem 通道)。写明而不做;若 T4 之后 renderer 能传
project target,这条要跟着补。
Refs #780 R2:R1 六条判 5 FIXED / 1 PARTIAL,但修复 diff 引入两条新问题。全部采纳。 [Blocker] 为闭合「预览要消费已装事实」而加的账本读取自己会写盘 parseLedger 看到坏 receipt / 坏 record 时会落一份 installs.json.evidence-<hex12> (ext-receipt-v2 r18 的字节级取证侧写)—— 那是一个写,直接违反 AC9; 启动期 recovery 非 clean 而跳过 migration、或账本在 barrier 之后变化时到得了。 修法:parseLedger 加 sideEffectFree(默认 false = 今天的行为,既有调用点一个不动), readLedgerV2 / readPackageLedgerStateV1 透传;预览路径传 true。 取证不丢:任何写路径的读仍会落它,而侧写本来就按损坏集哈希幂等。 补一条闸:账本里有坏 receipt ⇒ 预览后 evidence-* 零新增 + 账本 mtime/inode 未变。 [Major] K19 生产闸是假绿 旧夹具写 v:2 却带非空 V3 段、node 缺 componentId、installedGraphDigest 是手填常数 ⇒ 账本合同必然拒绝 ⇒ 走「读不出来」退路分支 ⇒ 删掉 K19 生产接线照样全绿。 新夹具:record 用生产 upsertRecordV2 造,owner token 用真 bundleOwner 算, installedGraphDigest 用真 computeInstalledGraphDigest 算,v:3; 并**先自证 readPackageLedgerStateV1 接受它**,再断言 K19。退路分支已删除。 [PARTIAL] 零写盘指纹范围 隔离 home 纳入指纹(四棵树)。范围外写盘与「写完恢复 mtime」明写而不做: 要堵那一类得在进程边界上拦,不在本票射程内。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R2 闭合(1 Blocker + 1 Major + 1 PARTIAL 全部采纳)推到 [Blocker] 我为闭合 #2 加的账本读取自己会写盘真的会。 修法(没用「复用启动期已解析状态」那条 —— 它要在 新闸:账本里放一条坏 receipt → 跑生产 handler 预览 → 断言 [Major] K19 生产闸是假绿你说得对,而且比我以为的更糟 —— 旧夹具三处都不合规: 修法:record 用生产 [PARTIAL] 零写盘指纹范围隔离 范围外写盘 + 写完恢复 mtime:明写而不做。 判据只覆盖「传给生产 handler 的那几个根」; 绕过实验(最终树全量重跑)。基线
|
| # | 改坏什么 | 结果 |
|---|---|---|
| D1 | 预览路径的账本读取改回会写取证文件 | RED — 「坏 receipt ⇒ evidence-* 零新增」 |
| D2 | 删掉 K19 生产接线(不传 installedPackageIds) |
RED — K19。R2 之前这条不会红,那正是假绿 |
| D2b | 保留接线但摘掉 duplicateImportNotice 产出 |
RED ×2 — K19 + AC8 |
| D3 | 往隔离 home 写一个文件 |
RED ×3 — 三条 AC9 指纹用例(纳入 home 之前它看不见) |
| C1 | 删掉 ext-ipc.ts 分流点 |
RED(子进程 0 pass / 1 fail) |
| C2 | 生产依赖里幂等重写 installs.json |
RED ×7 |
| C3 | 不传已装技能名 | RED |
| C4 | 包内重名不再逐组件跳过 | RED ×2(纯函数 + 生产) |
| C5 | symlink 技能回到枚举期滤掉 | RED ×2 |
| C6 | scan.failed 回到 fail-open |
RED |
| C7 | 技能载荷换回裸 readFileSync |
RED — 超 256KB SKILL.md(["payload-unreadable"] → []) |
| C8 | manifest 回到裸 readFileSync |
RED — 超大 manifest |
| C9 | G11 判据 > 64 收成 === 65 |
RED ×2(66 / 200) |
| C10 | R2-b 键存在性改回「要求非空标量」 | RED ×3(块式夹具 + 解析器单测 + 嵌套);真实语料 12 那条仍绿 |
全部还原后 git status 为空。C10 的「真实语料仍绿」是刻意保留的证据:真实语料验不出 R2-b,
只有 block-only 合成夹具能杀它。
门
| 门 | 结果 |
|---|---|
| north-star | ✓ 零上游文件改动 |
| 字面 NUL | ✓ 7324 个版本控制文件零命中 |
| typecheck | exit 2,与 base(origin/alpha 13a69d2)逐字节相同;packages/ui-mac 0 条 |
| unit ui-mac | 3706 pass / 0 fail(250 files;base 3648 / 0) |
| contracts-consumer / ext | 52 / 0 + 132 / 0 |
你没判错的地方 —— 但有一条要摆出来
我动了 ext-receipt-v2.ts。 那是基线 §10 给 T2 划的禁改文件(T1 没有这条明文约束),
改动面是三行:parseLedger 加一个默认 false 的参数、两个 read 入口透传、侧写多一个 &&。
默认路径行为逐字不变(3706 用例零回归可作证)。
我判断这是最小修法 —— 另外两条路都更差:①在预览路径上另写一个「只读版 parseLedger」= 手写替身,
账本文法有两个答案;②复用 ledgerReady 的解析结果 = 要挂快照,而快照会陈旧。
若你认为 T1 不该碰这个文件,请裁,我把它退回并改走 ①/② 里你指定的那条。
Fixes #780
Refs jinjunnn/alpha-work#49
大白话
用户选一个 Claude 插件目录,主进程只读不写地把它清点一遍,然后如实说清:哪些技能能装、
哪些装不上(说人话原因)、哪些组件类型和摆放方式这一版不支持。执行前后磁盘逐字节不变。
今天的行为:插件目录一路落到
collectImportSkillPayload,报一句「文件夹内没有 SKILL.md」——一句与真因毫无关系的错误信息,用户据它做不出任何正确动作。本票把它换成一份如实清点。
方案基线:
docs/design/2026-08-02-req128-phase3-local-claude-plugin-import.md§10 的T1-intake。改了什么
新增
main/claude-plugin-intake.ts—— 布局判定(六类不支持布局具名)、读plugin.json(version 选填)、枚举
skills/*/SKILL.md、逐个跑collectImportSkillPayload+parseSkillFrontmatter+ 独立 lstat 自包含扫描,产LocalPackagePreviewV1。ext-ipc在pickImportSkillDir之后加分流点。非插件目录行为逐字不变。test-fixtures/claude-plugin-corpus.json(1.42 MB)—— 本机真实语料的仓内夹具,由scripts/gen-claude-plugin-corpus-fixture.ts导出。SKILL.md 与 plugin.json 逐字保留(它们是解析器与引用扫描真正读的输入,手写复刻 = 替别人写文法),其余 664 个文件占位保
size/mode。测试把它摊成一棵真目录树再跑生产代码 —— 喂内存对象等于换成自拼的等价链。改动(两处,都是最小修法)
parseSkillFrontmatter交出顶层键集(基线 §14 R2-b)。控制字段的判据是「顶层键在不在」,与它有没有标量值无关;老的
fields字典要求冒号后有非空标量 ⇒ 块式写法整个不进字典。零新增解析器:不解析块式的值、不引 YAML、不解析嵌套(
ext-import-validate.ts抬头的PR fix(skills): 转义 skill-creator eval-viewer 的 </script> XSS(安全审查) #73 决策不动);顶层性由顶格判定,比
fields的line.trim()更严 —— 不会把metadata:下的allowed-tools:误报成顶层键。IMPORT_EXCLUDED_DIR_NAMES(基线 §14 R2-a 首选处置)。技能目录内含
node_modules/.git/__pycache__⇒ 具名拒绝;不处置就是「预览接受、装完缺件」。两处各写一份就是手写替身,故从一份导出。
真实语料实测(仓内夹具全量跑)
.claude-plugin/plugin.json).claude/skills/ manifestlessmath-olympiad)commands/agents/hooks/.mcp.jsonversion两处与基线不一致,如实记下(没有往基线上凑)
① 可装数是 132,不是 135。 两类拒绝重叠 3 个(
claude-security/example-command/codex-cli-runtime)⇒ 并集 27。基线的 135 =162 − 27,即把三份异常布局的 SKILL.md 也算进了分母;而那三份正是 G18 具名为「不支持的布局」的,结构上装不了。经本功能真正可装的是
159 − 27 = 132。测试两个都断言(132 是真值,135 作为基线那条恒等式)。② §14 R2-b 的绕过配方在真实语料上不成立。 基线写「把检测口径改回要求有标量值 ⇒ 块式那一半
必须变红」。实测:7 份块式
allowed-tools:的文件全部同时带一个标量控制字段(
user-invocable:/disable-model-invocation:)⇒ 技能级拒绝集两种口径都是 12。也就是说真实语料验不出 R2-b —— 只用它给这道闸打分就是假闸形态⑨。
真正能杀掉它的只有唯一控制字段是块式的合成夹具(本 PR 已建,见下方绕过实验 B1)。
R2-b 的修法仍然必要:它管的是可达性(用户可手选任意目录),不是当前语料的计数。
每道闸的绕过实验(工作树干净 ⇒ 改坏 ⇒ 跑 ⇒ 还原)
基线未变异:
67 pass / 0 fail。name/descriptionnode_modules/.git/__pycache__夹具)-perm -111)slice(0, 64)静默装前 64 个plugin.json才是包」commands/的那一行11 条全部变红,没有写不出绕过配方的闸。 全部还原后
git status为空。-t过滤器踩过一次坑:bun 的-t对本仓的 CJK 用例名一条都匹配不上,第一轮实验因此测到了别的文件里的用例(3 条假绿)。改成每次跑整两个文件 + 打印逐条失败用例名之后才是真的。
本地门
origin/alpha13a69d2)逐字节相同:205 行全在packages/app(worktree 软链node_modules的已知假红),packages/ui-mac0 条--no-verify推送:pre-push 跑的alpha-check.sh会因上面那条 typecheck 假红恒红。判据按仓规只认「与 base fail-set 相同」,已逐字节比对。
顺带被自己的闸抓到一次(值得记一笔)
NUL 闸在本 PR 上真的红了一次:我写的摘要分隔符与一处注释落盘成了字面 NUL(4 处)。
这正是
scripts/assert-no-nul-bytes.py抬头预言的传播路径(「写这份脚本的过程本身就是本类缺陷最常见的传播途径」)。已按它指定的转义写法修掉(运行时逐字节等价、grep 看得见)。
在这几个文件上
grep当时会给出假的「没有」 —— 定位靠的是闸报的字节偏移,不是 grep。范围纪律:主动没做的事
[T4-renderer]);preview→confirm 通道、包级字节预算、
local:双向闸、「列出已装本地包」只读 IPC 一个都没做([T3-channel])。本票的用户可见终态就是「如实说清楚」为止,分流点返回的是具名拒绝 + 预览数据。
ext-transaction.ts/package-admission.ts/ext-install-policy.ts。accepted的方案基线去对齐上面那两个数字 —— 基线的修订归编排者裁决。spy 版本要等
[T2]/[T3]有 confirm 之后才非空。已在此写明,不当作已做。references/*.md里的路径)。AC 措辞是「命中以下几类特征之一即拒」,不是「保证自包含」——按基线 §8 纪律 3,降级只许写成降级。