@tokensapi/dsh-plugin-check 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/dsh-plugin-check.mjs +20 -12
- package/docs/CHECKS.md +117 -18
- package/lib/client-check.mjs +98 -0
- package/lib/contract.mjs +130 -0
- package/lib/manifest-check.mjs +175 -138
- package/lib/patch-rows.mjs +126 -31
- package/lib/registry-check.mjs +78 -206
- package/lib/semver-rules.mjs +129 -0
- package/lib/smoke-check.mjs +13 -1
- package/lib/tarball.mjs +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ npx @tokensapi/dsh-plugin-check --package @scope/name@1.2.3 --runtime 0.1.3-alph
|
|
|
28
28
|
**阶段① 清单体检**(纯静态、离线、秒级):检查 package.json 与 cordis 补丁的声明是否合规。
|
|
29
29
|
**阶段② 隔离启动冒烟**(联网、约 1–3 分钟):在临时工作区按你声明的 peer 范围装出真实运行时,然后逐补丁行做真实网关启动时做的事——解析入口 → `import` → `new Context()` + `ctx.plugin()` 应用。三段都过,你的插件就不会以"装上即崩"的方式杀死用户的 Cowork。全程 `--ignore-scripts`,你的代码只在一次性子进程里运行,超时即杀。
|
|
30
30
|
|
|
31
|
-
清单存在 error
|
|
31
|
+
清单存在 error 时冒烟不执行——先修再跑。跳过冒烟时输出会说明**真实原因**(清单有 error / 补丁没有可用行 / 读不到 package.json / `--skip-smoke`),不会拿一个笼统的理由搪塞。
|
|
32
32
|
|
|
33
33
|
## 规则清单
|
|
34
34
|
|
|
@@ -37,16 +37,18 @@ npx @tokensapi/dsh-plugin-check --package @scope/name@1.2.3 --runtime 0.1.3-alph
|
|
|
37
37
|
| 规则 | 级别 | 内容 | 拦的是什么事故 |
|
|
38
38
|
|---|---|---|---|
|
|
39
39
|
| C0-contract | warning | `--runtime` 不在本工具核对过的版本里时提示 | 拿过时的上游口径当结论 |
|
|
40
|
-
| M1-manifest | error/warning | package.json 合法,name/version
|
|
40
|
+
| M1-manifest | error/warning | package.json 合法,name/version 齐全且包名满足市场约束(不在上游黑名单里);预发布版本给出上架提示 | 无法入库 |
|
|
41
41
|
| M2-lifecycle | warning | 不要声明 `preinstall/install/postinstall/prepare`(构建用 `prepack`) | 受控安装未加 `--ignore-scripts`:脚本要么在用户机器上执行任意代码,要么被包管理器默认策略拦下、脚本产物缺失导致装完即坏 |
|
|
42
42
|
| M3-core-peer | error | `@deepseek-ai/cordis*`、`@deepseek-ai/dsh*` 只能是 peerDependencies | 双内核实例 → Symbol 不等 → 服务注册对不上 → **Cowork 启动失败** |
|
|
43
|
-
| M4-bundle | error | `dsh.bundle.patch`
|
|
43
|
+
| M4-bundle | error | `dsh.bundle.patch` 必填、文件存在、能解析出插件行,且路径形状满足上游 `safeBundlePatch`(相对路径、无反斜杠、无 `..`、段内无冒号、≤512 字节) | 宿主无法把插件挂进加载树;形状不合规则受控安装验证器直接拒绝,用户端只剩手动命令 |
|
|
44
44
|
| M5-row-scope | error | 补丁行只能指向本包(或其子路径) | 插件行劫持挂载其他包 |
|
|
45
45
|
| M6-engine | error/warning | `dsh.engine` 声明目标运行时 SemVer 范围;与 `--runtime` 不相交为 error(未声明为 warning——上游目前不强制读取此字段) | 装进不适配的宿主版本 |
|
|
46
|
-
| M7-peer-range | warning |
|
|
47
|
-
| M8-node-engines | warning | 建议声明 `engines.node`
|
|
46
|
+
| M7-peer-range | error/warning | 核心 peer(`@deepseek-ai/cordis*`、`@deepseek-ai/dsh*`)的范围必须合法;`dsh-*` 的范围应包含目标运行时 | 范围写坏则受控安装装不出来;不含目标运行时则宿主升级后接口错配 |
|
|
47
|
+
| M8-node-engines | warning | 建议声明 `engines.node`,且要与宿主的 `^22.19.0 \|\| >=24.0.0` 有交集 | 语法/API 不可用;无交集则安装阶段报 unsupported engine |
|
|
48
48
|
| M9-license | warning | 建议声明 license | 分发合规 |
|
|
49
49
|
| M10-secrets | error/warning | 发布内容不得包含 `.env`、私钥等凭据样式文件 | 凭据泄露 |
|
|
50
|
+
| M11-row-identity | error | 补丁行 `id` 同层不得重复、不得含 `:`、不得占用宿主保留 id(`settings`、`web-runtime` 等) | **重复 id 会让用户整个 Desktop 起不来**;而市场安装装完从不重新解析补丁、也没有回滚,一路绿灯装上、下次开机才炸 |
|
|
51
|
+
| M12-client | error/warning | 声明 `dsh.client` 时,字段形状与 `exports["./client"]` 必须成立 | 宿主构造期同步解析,一份写坏会让整个 client-modules 失败——**同宿主其他插件的前端模块一起挂** |
|
|
50
52
|
| S1-pack | error | `npm pack --ignore-scripts` 必须成功 | 包本身发布不出来 |
|
|
51
53
|
| S2-install | error/warning | 按你声明的依赖必须装得出来;宿主内核包(`@deepseek-ai/*`)的内部版本在公开源取不到时降级为 warning 并跳过冒烟 | 用户受控安装会同样失败 |
|
|
52
54
|
| S3/S4-apply | error | 每个补丁行:入口可解析、`import` 不抛、`ctx.plugin()` 应用不抛(声明 `inject` 等待服务注入属正常,不算失败) | **启动链击穿**——一行 import 失败会拖死整棵插件树 |
|
package/bin/dsh-plugin-check.mjs
CHANGED
|
@@ -61,19 +61,30 @@ const manifestResult = checkManifest(options.dir, { runtime: options.runtime })
|
|
|
61
61
|
findings.push(...manifestResult.findings)
|
|
62
62
|
phases.push({ phase: 'manifest', findings: manifestResult.findings })
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
/**
|
|
65
|
+
* 冒烟跳过的**真实**原因。以前无论因为什么跳过都打印"清单体检存在
|
|
66
|
+
* error",作者拿着一份 0 error 的报告被告知有 error,只能干瞪眼。
|
|
67
|
+
* @returns {string | undefined} 不跳过时返回 undefined。
|
|
68
|
+
*/
|
|
69
|
+
function smokeSkipReason() {
|
|
70
|
+
if (options.skipSmoke) return '按 --skip-smoke 跳过'
|
|
71
|
+
if (manifestResult.manifest === undefined) return '读不到 package.json,清单体检未完成'
|
|
72
|
+
if (manifestResult.findings.some(finding => finding.level === 'error')) return '清单体检存在 error,冒烟不再执行'
|
|
73
|
+
if (manifestResult.rows === undefined) return 'dsh.bundle.patch 缺失或补丁文件读不到,没有可冒烟的插件行'
|
|
74
|
+
if (manifestResult.rows.length === 0) return '补丁未声明任何插件行,没有可冒烟的对象'
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const skipReason = smokeSkipReason()
|
|
79
|
+
const smokeSkipped = skipReason !== undefined
|
|
80
|
+
if (!smokeSkipped) {
|
|
70
81
|
const smokeResult = checkSmoke(options.dir, manifestResult.manifest, manifestResult.rows, {
|
|
71
82
|
keepWorkspace: options.keepWorkspace,
|
|
72
83
|
})
|
|
73
84
|
findings.push(...smokeResult.findings)
|
|
74
85
|
phases.push({ phase: 'smoke', findings: smokeResult.findings, workspace: smokeResult.workspace })
|
|
75
86
|
} else if (!options.skipSmoke) {
|
|
76
|
-
phases.push({ phase: 'smoke', skipped:
|
|
87
|
+
phases.push({ phase: 'smoke', skipped: skipReason })
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
const errors = findings.filter(finding => finding.level === 'error')
|
|
@@ -90,6 +101,7 @@ if (options.json) {
|
|
|
90
101
|
contract: CONTRACT,
|
|
91
102
|
errors,
|
|
92
103
|
warnings,
|
|
104
|
+
infos,
|
|
93
105
|
phases,
|
|
94
106
|
}, undefined, 2)}\n`)
|
|
95
107
|
process.exit(failed ? 1 : 0)
|
|
@@ -108,11 +120,7 @@ for (const finding of infos) process.stdout.write(` ✅ ${finding.message}
|
|
|
108
120
|
`)
|
|
109
121
|
if (findings.length === 0) process.stdout.write(' 全部规则通过\n')
|
|
110
122
|
process.stdout.write('\n')
|
|
111
|
-
if (smokeSkipped
|
|
112
|
-
process.stdout.write('清单存在 error,隔离冒烟未执行;修复后重跑。\n')
|
|
113
|
-
} else if (options.skipSmoke) {
|
|
114
|
-
process.stdout.write('已按 --skip-smoke 跳过隔离冒烟。\n')
|
|
115
|
-
}
|
|
123
|
+
if (smokeSkipped) process.stdout.write(`隔离冒烟未执行:${skipReason}。\n`)
|
|
116
124
|
process.stdout.write(failed
|
|
117
125
|
? `结论: 不合格(${errors.length} error / ${warnings.length} warning)\n`
|
|
118
126
|
: `结论: 合格(0 error / ${warnings.length} warning)\n`)
|
package/docs/CHECKS.md
CHANGED
|
@@ -38,31 +38,55 @@
|
|
|
38
38
|
|
|
39
39
|
| 入口 | 拿到的是什么 | 跑哪些规则 |
|
|
40
40
|
| --- | --- | --- |
|
|
41
|
-
| CLI `dsh-plugin-check [目录]` | 本地工作区文件 | M1–
|
|
42
|
-
| CLI `--package <name@ver>` | `npm pack` 下载并解包的目录 | M1–
|
|
43
|
-
| 会话内 `plugin_check(path=…)` | 本地工作区文件 | M1–
|
|
44
|
-
| 会话内 `plugin_check(package=…)` | registry 版本清单 + tarball 里抽出的 `cordis.patch.yml` | M1–
|
|
41
|
+
| CLI `dsh-plugin-check [目录]` | 本地工作区文件 | M1–M12 + S1–S4(完整) |
|
|
42
|
+
| CLI `--package <name@ver>` | `npm pack` 下载并解包的目录 | M1–M12 + S1–S4(完整) |
|
|
43
|
+
| 会话内 `plugin_check(path=…)` | 本地工作区文件 | M1–M12 |
|
|
44
|
+
| 会话内 `plugin_check(package=…)` | registry 版本清单 + tarball 里抽出的 `cordis.patch.yml` | M1–M12(除 M10) |
|
|
45
45
|
|
|
46
46
|
会话内不跑冒烟:冒烟要装依赖、起子进程、执行被测插件代码,这些不能发生在用户的
|
|
47
47
|
Cowork 宿主进程里。所以会话内的结论只覆盖清单侧,输出里的 `note` 会如实说明这
|
|
48
48
|
一点,不冒充完整体检。
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
### 同源:两条路径不允许对同一个包给出不同结论
|
|
51
|
+
|
|
52
|
+
- **只看清单就能判的规则只有一份实现** —— `lib/manifest-check.mjs` 的
|
|
53
|
+
`checkManifestFields`,注册表路径的 `checkPublishedManifest` 就是它的直接调用。
|
|
54
|
+
版本类判定进一步收在 `lib/semver-rules.mjs`,`dsh.client` 收在
|
|
55
|
+
`lib/client-check.mjs`,路径/包名/保留 id 这些谓词收在 `lib/contract.mjs`。
|
|
56
|
+
- **M4/M5/M11 的补丁内容部分也只有一份** —— `lib/patch-rows.mjs` 的
|
|
57
|
+
`checkPatchContent`,两边只是取文件的方式不同(读盘 vs 解 tarball)。
|
|
58
|
+
- `tests/cross-path.test.mjs` 是这条约束的闸门:同一份清单喂给两条路径,
|
|
59
|
+
逐条比对 `level:rule`,不一致直接红。
|
|
60
|
+
|
|
61
|
+
这些都是踩过坑之后收的。0.3.x 时两边各写各的:会话内看不到 M5,把「装上即崩」的
|
|
62
|
+
插件判成合格;注册表侧还自带一套手写迷你 SemVer,把 `>=1.2`、`1.x`、`1.2.0 - 2.0.0`
|
|
63
|
+
这些完全合法的 npm 范围判成「不可识别」→ error,合格插件被判不合格。
|
|
64
|
+
|
|
65
|
+
**唯一允许的差异是 M10-secrets**:它要扫工作区文件,registry 路径根本没有工作区。
|
|
66
|
+
这是有意的取舍,不是漏。
|
|
53
67
|
|
|
54
68
|
---
|
|
55
69
|
|
|
56
70
|
## 2. 清单规则(M 系列)
|
|
57
71
|
|
|
58
|
-
判定实现:`lib/manifest-check.mjs`
|
|
59
|
-
|
|
72
|
+
判定实现:`lib/manifest-check.mjs` 的 `checkManifestFields` —— **两条路径共用这一份**
|
|
73
|
+
(见 §1)。版本范围一律用真 `semver`,并且一律带 `includePrerelease`:目标运行时本身
|
|
74
|
+
长期是 `0.1.3-alpha.1` 这种预发布版,不带这个选项 semver 会把它排除在任何范围之外,
|
|
75
|
+
得出「谁都不兼容」的荒谬结论。
|
|
60
76
|
|
|
61
77
|
### M1-manifest — 清单本身可用
|
|
62
78
|
|
|
63
79
|
- **error**:`package.json` 不存在 / 不是合法 JSON / `name` 缺失 / `version` 不是
|
|
64
80
|
合法 SemVer。
|
|
81
|
+
- **error**:`name` 不满足市场的包名约束(小写字母或数字开头,只含 `a-z 0-9 . _ -`,
|
|
82
|
+
可带一层 `@scope`,总长 ≤214),或落在上游黑名单(`dsh-plugin-desktop`、
|
|
83
|
+
`dsh-plugin-desktop-beta`、`dsh-community-market`、`@deepseek-ai/dsh-desktop-app`)。
|
|
84
|
+
这两类包无论内容多干净都装不进市场 —— 验证器在看清单内容之前就拒了。
|
|
85
|
+
**上游锚点**:`install/service.ts:26` 的 `PACKAGE_NAME_PATTERN`、
|
|
86
|
+
`desktop-plugins.ts:193` 的 `safePackageName`、`BLOCKED_PRODUCT_PACKAGES`。
|
|
65
87
|
- **warning**:`version` 是预发布版(如 `0.2.0-beta.1`)。
|
|
88
|
+
- **warning**:声明了 `dsh.profile` 或 `dsh.moduleFallback` —— 这两个是宿主/Profile
|
|
89
|
+
侧字段,插件包声明它没有作用,多半是从 Profile 的 `package.json` 误抄过来的。
|
|
66
90
|
- **为什么只是 warning**:上游市场的 npm 验证器要求 `latest` 是**稳定的精确版**,
|
|
67
91
|
预发布版拿不到一键安装按钮,只会退回手动命令 —— 但插件完全可以把预发布发在
|
|
68
92
|
`next` 之类的 dist-tag 上,让 `latest` 保持稳定。工具看到的是清单里的版本号,
|
|
@@ -105,14 +129,19 @@ Cowork 宿主进程里。所以会话内的结论只覆盖清单侧,输出里的
|
|
|
105
129
|
- **error**:`dsh.bundle.patch` 缺失;指向的文件不存在(目录体检)或不在发布内容里
|
|
106
130
|
(会话内体检,常见原因是 `files` 白名单漏了它);补丁不是合法 YAML;补丁没声明
|
|
107
131
|
任何插件行。
|
|
132
|
+
- **error**:`dsh.bundle.patch` 的**路径形状**不满足上游 `safeBundlePatch` —— 绝对路径
|
|
133
|
+
(`/abs/patch.yml`)、越出包根(`../outside/patch.yml`)、含反斜杠(`dist\patch.yml`)、
|
|
134
|
+
空路径段(`./a//b.yml`)、段内含冒号(`./c:/patch.yml`)、含 NUL、超过 512 字节。
|
|
135
|
+
这一条 0.3.x 时是**放行**的:这类包能装、能跑、体检全绿,但市场受控安装的验证器
|
|
136
|
+
一律拒绝 —— 用户端永远看不到一键安装按钮,只会看到手动安装命令。
|
|
108
137
|
- **warning**:会话内体检取不到 tarball(离线/超时/过大)—— 如实标注「补丁行本次
|
|
109
138
|
未检查」,绝不因网络问题把插件判成不合格。
|
|
110
139
|
- **判什么**:宿主靠 `dsh.bundle.patch` 把插件挂进 cordis 加载树。取不到补丁,插件
|
|
111
140
|
根本挂不上。
|
|
112
|
-
- **上游锚点**:`dsh-community-market/src/install/service.ts` → `safeBundlePatch()`
|
|
113
|
-
(
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
- **上游锚点**:`dsh-community-market/src/install/service.ts:268` → `safeBundlePatch()`
|
|
142
|
+
(`lib/contract.mjs` 里逐字照抄),在 `createNpmRegistryVerifier` 里强制;宿主侧
|
|
143
|
+
`dsh-plugin-desktop/src/profile.ts` 的 `DESKTOP_PATCH_PATH` 与 `profile-checkpoint.ts`
|
|
144
|
+
里的 `cordis.patch.yml` 条目。
|
|
116
145
|
|
|
117
146
|
### M5-row-scope — 补丁行只能挂自己
|
|
118
147
|
|
|
@@ -138,14 +167,24 @@ Cowork 宿主进程里。所以会话内的结论只覆盖清单侧,输出里的
|
|
|
138
167
|
|
|
139
168
|
### M7-peer-range — peer 范围与运行时的交集
|
|
140
169
|
|
|
141
|
-
- **error
|
|
170
|
+
- **error**:核心 peer 的范围不是合法 SemVer 范围。范围口径是全部
|
|
171
|
+
`isIdentityCore` 的包 —— `@deepseek-ai/cordis`、`@deepseek-ai/cordis-*`、
|
|
172
|
+
`@deepseek-ai/dsh*`。0.3.x 时过滤写的是 `startsWith('@deepseek-ai/dsh')`,
|
|
173
|
+
于是把 `@deepseek-ai/cordis` 的范围写成 `not-a-range` **完全不报** —— 偏偏
|
|
174
|
+
cordis 正是 S2 冒烟真正要装的那个包,范围写坏下一步必然装不出来。
|
|
142
175
|
- **warning**:给了 `--runtime` 而 peer 范围不含它。
|
|
176
|
+
- **「是否含目标运行时」只对 `@deepseek-ai/dsh*` 判**:核心包里只有它跟着 DSH 运行时
|
|
177
|
+
版本走,`@deepseek-ai/cordis` 自成一条 4.x 版本线。拿 `"4.0.1 || 4.0.2"` 去比对运行时
|
|
178
|
+
`0.1.3-alpha.1` 是范畴错误,不是插件的问题(本工具自己的清单就是这个形状)。
|
|
179
|
+
区分写在 `lib/contract.mjs` 的 `isRuntimeVersioned`。
|
|
143
180
|
- **为什么只是 warning**:范围不含目标运行时,不代表当场就崩 —— 它预示的是宿主升级
|
|
144
181
|
之后的接口错配。判死会挡住「暂时还能跑、只是没来得及放宽范围」的插件。
|
|
145
182
|
|
|
146
183
|
### M8-node-engines / M9-license
|
|
147
184
|
|
|
148
185
|
- **warning**:`engines.node` 未声明(宿主跑在 Node 22+)、`license` 未声明。
|
|
186
|
+
- **warning**:`engines.node` 声明了但不是合法范围,或与宿主的
|
|
187
|
+
`^22.19.0 || >=24.0.0` **无交集** —— 包管理器会在安装阶段报 unsupported engine。
|
|
149
188
|
- 都是发布卫生问题,不影响能不能装,所以都不判死。
|
|
150
189
|
|
|
151
190
|
### M10-secrets — 别把凭据发上去
|
|
@@ -155,6 +194,49 @@ Cowork 宿主进程里。所以会话内的结论只覆盖清单侧,输出里的
|
|
|
155
194
|
把它带上。
|
|
156
195
|
- 只有目录体检能做后半条(要看文件系统);会话内 `package=` 模式看不到工作区。
|
|
157
196
|
|
|
197
|
+
### M11-row-identity — 补丁行的 id 会崩掉整个 Desktop
|
|
198
|
+
|
|
199
|
+
- **error**:同一层里 `id` 重复;`id` 含 `:`;`id` 撞宿主保留行 id
|
|
200
|
+
(`settings`、`web-runtime`、`desktop-webserver`、`community-market`、`dsh-market`);
|
|
201
|
+
行 `name` 撞宿主保留包名(`dsh-community-market`、`dshmarket`)。
|
|
202
|
+
- **判什么**:这一条查的不是「插件自己能不能跑」,而是「装上之后用户的 Desktop
|
|
203
|
+
还起不起得来」。三种写法各自对应一个宿主硬失败:
|
|
204
|
+
|
|
205
|
+
| 写法 | 宿主真实行为 | 上游锚点 |
|
|
206
|
+
| --- | --- | --- |
|
|
207
|
+
| 同层 `id` 重复 | `TypeError: duplicate loader entry id: <id>`;Desktop 还有一道前置检查先抛 `duplicate loader entry id "<id>" in the composed profile` | `vendor/loader/src/config/group.ts:64`;`dsh-plugin-desktop/src/profile.ts:633-646`(在 `:949` 对**合成后**的整份行集调用) |
|
|
208
|
+
| `id` 含 `:` | `EntryTree.sep === ':'`,嵌套行的寻址被破坏 | `vendor/loader/src/config/tree.ts:8` |
|
|
209
|
+
| 撞保留身份 | 行被静默剥离,并让整个 Market provider 以 `conflicting Market provider Loader identity was removed` 失败;`settings` / `web-runtime` 则直接抛错 | `profile.ts:122-129, 704-750, 913, 955-958, 977-979`;`desktop-market.ts:28-39` |
|
|
210
|
+
|
|
211
|
+
- **为什么必须是 error**:市场安装路径装完**从不重新解析** `cordis.patch.yml`
|
|
212
|
+
(`install/service.ts:626-665` 只比对版本号,而且**没有回滚**)。所以这类包一路绿灯
|
|
213
|
+
装上,下次开机才炸 —— 到那时用户面对的是一个打不开的 Cowork,而不是一个坏掉的插件。
|
|
214
|
+
- **唯一性按「层」判**,与上游 `assertUniqueEntryIds` 同构:它每递归一层新建一个 Set,
|
|
215
|
+
所以父层与子层的同名 `id` 不算冲突。`group: true` 行的 `config` 数组会递归进去。
|
|
216
|
+
- **查不到的部分(如实说明)**:跨插件的 id 冲突、以及与宿主自身行集的冲突,需要完整
|
|
217
|
+
的宿主行清单;那要求本工具跟宿主行集同步演进,成本与收益不匹配。这里只查已知保留 id。
|
|
218
|
+
- **顶层的「修改」操作不参与判定**:形如 `- id: web-runtime` + `config:`(没有 `insert:`)
|
|
219
|
+
的顶层条目是**按 id 修改一条已存在的行**,不是新增。宿主自己的
|
|
220
|
+
`dsh-plugin-desktop/cordis.patch.yml` 就这么用,判它反而是误伤。
|
|
221
|
+
|
|
222
|
+
### M12-client — `dsh.client` 声明错会拖垮别的插件
|
|
223
|
+
|
|
224
|
+
- **error**:`dsh.client` 不是对象;`platform` 缺失或不是字符串;`inject` / `external`
|
|
225
|
+
不是字符串数组;`external` 条目不是精确的裸包根(scope 名恰好两段、非 scope 名不含
|
|
226
|
+
`/`)或含本包自身;`immediately` 不是布尔值;声明了 `dsh.client` 却没有
|
|
227
|
+
`exports["./client"]`,或它不是字符串 / 不是带字符串 `default` 的对象。
|
|
228
|
+
- **warning**:`platform !== 'web'` —— 宿主只把 `platform === 'web'` 的行当作前端模块,
|
|
229
|
+
其他取值等于这段声明不会生效。
|
|
230
|
+
- **为什么是 error 而不是「你自己的事」**:宿主的 client-modules 在**构造期同步**解析
|
|
231
|
+
所有已加载包的 `dsh.client`,一份声明写坏会抛出并让整个 client-modules fiber FAIL ——
|
|
232
|
+
受害的不止这个插件,而是同一宿主里所有需要前端模块的插件。
|
|
233
|
+
- **字段全集**就是 `{ platform, inject?, external?, immediately? }`,未知键被忽略。
|
|
234
|
+
- **上游锚点**:`packages/client/modules/src/index.ts` → `parseDshClient`(`:200-221`)、
|
|
235
|
+
`exactPackageSpecifier`(`:192-198`)、`clientExportOf`(`:224-234`)、
|
|
236
|
+
自引用检查(`:454-461`)、`platform === 'web'` 过滤(`:756`)、
|
|
237
|
+
缺 `./client` 的抛出(`:760-763`)。
|
|
238
|
+
- 没有 `dsh.client` 的插件(绝大多数)完全不产生任何发现。
|
|
239
|
+
|
|
158
240
|
---
|
|
159
241
|
|
|
160
242
|
## 3. 隔离冒烟(S 系列,只在 CLI)
|
|
@@ -198,6 +280,16 @@ S4 之所以要在子进程里做,是因为它**真的执行插件代码**:顶
|
|
|
198
280
|
用的是宽容 schema(未知标签收敛成占位对象),行照常提取,该行的 config 标记为
|
|
199
281
|
不可静态求值,冒烟时**以空配置 apply**,并给一条 warning 说明。不因为看不懂配置
|
|
200
282
|
就拒绝这个插件。
|
|
283
|
+
|
|
284
|
+
> 这条到 0.4.0 才真正做到。0.3.x 时宽容标签只注册了 `'!'` 前缀,而 js-yaml 的
|
|
285
|
+
> multi type 查找是**对解析后的完整标签串做前缀匹配**:`!env FOO` 解析成 `!env`
|
|
286
|
+
> (命中),`!!js foo` 解析成 `tag:yaml.org,2002:js`(**不命中**)。于是宿主**唯一**的
|
|
287
|
+
> 动态配置写法反而被判成「补丁不是合法 YAML」→ M4-bundle error —— 而宿主自带的
|
|
288
|
+
> 6 份 `cordis.patch.yml` 有 5 份在用它(如 dsh-base 的
|
|
289
|
+
> `root: !!js dshHomePath('sessions')`),照抄宿主惯用法的插件因此被判不合格。
|
|
290
|
+
> 现在两个前缀都注册(`'!'` 与 `'tag:yaml.org,2002:js'`,后者同时覆盖裸 `!!js`
|
|
291
|
+
> 与 `!!js/function`),并拿这 6 份真实补丁做过回归:全部 `problems === []`、行数 > 0。
|
|
292
|
+
> 标签定义见 `deepseek-harness/scripts/cordis-yaml.ts`。
|
|
201
293
|
4. **`inject` 声明的服务没注入** —— 插件声明了依赖服务而冒烟环境里没有,cordis 会
|
|
202
294
|
正常停车等待。这是预期行为,不算失败。
|
|
203
295
|
|
|
@@ -210,17 +302,24 @@ S4 之所以要在子进程里做,是因为它**真的执行插件代码**:顶
|
|
|
210
302
|
|
|
211
303
|
| 规则 | 去看什么 | 什么情况下必须改 |
|
|
212
304
|
| --- | --- | --- |
|
|
213
|
-
| M1 | market `src/install/service.ts` → `stableExactVersion` | 稳定版要求放宽/收紧 →
|
|
305
|
+
| M1 | market `src/install/service.ts` → `stableExactVersion`、`PACKAGE_NAME_PATTERN`(`:26`);`desktop-plugins.ts:193` → `safePackageName`、`BLOCKED_PRODUCT_PACKAGES` | 稳定版要求放宽/收紧 → 调级别与措辞;包名约束或黑名单变化 → 同步 `lib/contract.mjs` |
|
|
214
306
|
| M2 | 同上 → `createNpmRegistryVerifier` 是否重新检查 `scripts`;`installOptions()` 是否加了 `--ignore-scripts`;Profile 的 `pnpm-workspace.yaml` 是否配了 `onlyBuiltDependencies` | 验证器重新拒绝脚本 → 升回 error;安装加了 `--ignore-scripts` → 改措辞 |
|
|
215
307
|
| M3 | `@deepseek-ai/cordis` 主版本、dsh 服务注册方式 | 身份对齐机制改变 → 重新划 `isIdentityCore` 的范围 |
|
|
216
|
-
| M4 |
|
|
308
|
+
| M4 | market `src/install/service.ts:268` → `safeBundlePatch`(本仓逐字照抄在 `lib/contract.mjs`);`dsh-plugin-desktop/src/profile.ts` 的补丁路径 | 谓词改一个字 → 同步照抄,否则我们放行的包上游照拒 |
|
|
217
309
|
| M5 | `dsh-plugin-desktop/src/module-resolution.ts` → `selectedOverlayCandidate` / `PackageOverlayNotFoundError` | 行 `name` 的解析语义变化 → 这条必须跟着改 |
|
|
218
310
|
| M6 | 全仓搜 `dsh.engine` 有没有被真正读取 | 一旦上游开始强制 → 「未声明」从 warning 升 error |
|
|
219
|
-
| M7 | 宿主提供的 `@deepseek-ai/dsh*`
|
|
220
|
-
| M8/M9 | 宿主
|
|
311
|
+
| M7 | 宿主提供的 `@deepseek-ai/dsh*` 版本;`@deepseek-ai/cordis` 是否并入同一条版本线 | 版本变了只需更新 `CONTRACT.runtimes`;版本线合并 → 改 `isRuntimeVersioned` |
|
|
312
|
+
| M8/M9 | 宿主 `engines.node`(现为 `^22.19.0 \|\| >=24.0.0`,记在 `HOST_NODE_ENGINES`)、上架元数据要求 | 宿主 Node 区间变化 → 同步常量 |
|
|
221
313
|
| M10 | 无上游依赖(发布卫生) | 基本不用改 |
|
|
314
|
+
| M11 | `vendor/loader/src/config/group.ts:64`(duplicate id 抛出)、`tree.ts:8`(`EntryTree.sep`)、`dsh-plugin-desktop/src/profile.ts:633-646` → `assertUniqueEntryIds`、`desktop-market.ts:28-39` → `DESKTOP_MARKET_IDENTITIES` | 分隔符或保留 id 集合变化 → 同步 `RESERVED_ROW_IDS` / `RESERVED_ROW_NAMES`;唯一性作用域改成全局 → 改 `checkRowIdentity` 的按层逻辑 |
|
|
315
|
+
| M12 | `packages/client/modules/src/index.ts:200-221` → `parseDshClient`;`:192-198` → `exactPackageSpecifier`;`:224-234` → `clientExportOf` | 字段全集或接受形状变化 → 同步 `lib/client-check.mjs` |
|
|
222
316
|
| S1–S4 | cordis 的 `new Context()` / `ctx.plugin()` API | cordis 主版本升级 → 复核 `RUNNER_SOURCE` |
|
|
223
317
|
|
|
318
|
+
另外一条**没有对应规则、但复核时值得看一眼**的:`scripts/verify-cordis-config.ts` 要求
|
|
319
|
+
「补丁引用的包名必须在自己的 `dependencies` 里」。这条**故意不照搬** —— 它是 monorepo
|
|
320
|
+
内部约束,而第三方插件的核心包必须走 `peerDependencies`(M3),照搬会与 M3 直接打架、
|
|
321
|
+
制造假报。
|
|
322
|
+
|
|
224
323
|
复核完成后,请一并更新 `lib/contract.mjs` 里的 `verifiedAt`、`upstream`、`runtimes`
|
|
225
324
|
和 `runtimeRange`,再 bump 版本发布 —— 这样用户从输出的基线一行就能看出「这套结论
|
|
226
325
|
是照着哪个上游核对的」。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* M12-client:dsh.client 声明
|
|
3
|
+
* ============================================================
|
|
4
|
+
* 为什么这条是 error 而不是"你自己的事":宿主的 client-modules 在
|
|
5
|
+
* **构造期同步**解析所有已加载包的 dsh.client(parseDshClient,
|
|
6
|
+
* packages/client/modules/src/index.ts:200-221),一份声明写坏会抛出
|
|
7
|
+
* 并让整个 client-modules fiber FAIL —— 受害的不止这个插件,而是同
|
|
8
|
+
* 一宿主里所有需要前端模块的插件。
|
|
9
|
+
*
|
|
10
|
+
* 字段全集就是 { platform, inject?, external?, immediately? }
|
|
11
|
+
* (同文件 :51-64),未知键被忽略。
|
|
12
|
+
* ============================================================ */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 上游 exactPackageSpecifier(index.ts:192-198):只接受精确的裸包根,
|
|
16
|
+
* scope 包恰好两段、非 scope 包不含 "/"。
|
|
17
|
+
* @param {string} specifier
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function isExactPackageSpecifier(specifier) {
|
|
21
|
+
if (specifier.startsWith('@')) {
|
|
22
|
+
const parts = specifier.split('/')
|
|
23
|
+
return parts.length === 2 && parts.every(Boolean)
|
|
24
|
+
}
|
|
25
|
+
return specifier.length > 0 && !specifier.includes('/')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 上游 clientExportOf(index.ts:224-234)接受的两种形状。 */
|
|
29
|
+
function clientExportProblem(exportsField) {
|
|
30
|
+
if (typeof exportsField !== 'object' || exportsField === null) return 'missing'
|
|
31
|
+
const client = exportsField['./client']
|
|
32
|
+
if (client === undefined) return 'missing'
|
|
33
|
+
if (typeof client === 'string') return undefined
|
|
34
|
+
if (typeof client === 'object' && client !== null && typeof client.default === 'string') return undefined
|
|
35
|
+
return 'malformed'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 对一份清单执行 M12 判定。
|
|
40
|
+
* 没有 dsh.client 的插件(绝大多数)直接返回空,不产生任何噪音。
|
|
41
|
+
* @param {object} manifest - package.json / registry 版本文档。
|
|
42
|
+
* @returns {Array<{level: 'error'|'warning', rule: string, message: string}>}
|
|
43
|
+
*/
|
|
44
|
+
export function checkClientDeclaration(manifest) {
|
|
45
|
+
const findings = []
|
|
46
|
+
const error = (message) => findings.push({ level: 'error', rule: 'M12-client', message })
|
|
47
|
+
const warning = (message) => findings.push({ level: 'warning', rule: 'M12-client', message })
|
|
48
|
+
|
|
49
|
+
const declaration = manifest.dsh?.client
|
|
50
|
+
if (declaration === undefined) return findings
|
|
51
|
+
|
|
52
|
+
if (typeof declaration !== 'object' || declaration === null || Array.isArray(declaration)) {
|
|
53
|
+
error('dsh.client 不是对象;宿主解析到非对象声明会同步抛错,'
|
|
54
|
+
+ '连带让整个 client-modules 失败(同宿主其他插件的前端模块一起挂)')
|
|
55
|
+
return findings
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof declaration.platform !== 'string') {
|
|
59
|
+
error('dsh.client.platform 缺失或不是字符串;宿主 parseDshClient 会同步抛错')
|
|
60
|
+
} else if (declaration.platform !== 'web') {
|
|
61
|
+
warning(`dsh.client.platform 是 "${declaration.platform}";`
|
|
62
|
+
+ '宿主只把 platform === "web" 的行当作前端模块,其他取值等于这段声明不会生效')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const field of ['inject', 'external']) {
|
|
66
|
+
const value = declaration[field]
|
|
67
|
+
if (value === undefined) continue
|
|
68
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
|
|
69
|
+
error(`dsh.client.${field} 必须是字符串数组`)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (field !== 'external') continue
|
|
73
|
+
const packageName = typeof manifest.name === 'string' ? manifest.name : ''
|
|
74
|
+
for (const item of value) {
|
|
75
|
+
if (!isExactPackageSpecifier(item)) {
|
|
76
|
+
error(`dsh.client.external 的 "${item}" 不是精确的裸包名;`
|
|
77
|
+
+ 'scope 包必须恰好两段(@scope/name),非 scope 包不能含 "/"')
|
|
78
|
+
} else if (packageName !== '' && item === packageName) {
|
|
79
|
+
error(`dsh.client.external 不能包含本包自身 "${item}";`
|
|
80
|
+
+ '宿主会判定这一行"请求了自己提供的模块"并抛错')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (declaration.immediately !== undefined && typeof declaration.immediately !== 'boolean') {
|
|
86
|
+
error('dsh.client.immediately 必须是布尔值')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const exportProblem = clientExportProblem(manifest.exports)
|
|
90
|
+
if (exportProblem === 'missing') {
|
|
91
|
+
error('声明了 dsh.client 却没有 exports["./client"];'
|
|
92
|
+
+ '宿主会以 "declares dsh.client but exports no ./client bundle" 抛错')
|
|
93
|
+
} else if (exportProblem === 'malformed') {
|
|
94
|
+
error('exports["./client"] 必须是字符串,或带字符串 default 的对象')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return findings
|
|
98
|
+
}
|
package/lib/contract.mjs
CHANGED
|
@@ -78,3 +78,133 @@ export function lifecycleMessage(script) {
|
|
|
78
78
|
+ `要么在用户机器上执行任意代码,要么被包管理器默认策略拦下、脚本产物缺失导致插件装完即坏;`
|
|
79
79
|
+ `构建请改用 prepack`
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/* ============================================================
|
|
83
|
+
* 上游谓词的本地副本
|
|
84
|
+
* ============================================================
|
|
85
|
+
* 下面几个判定逐字照抄上游,注释里标出锚点。照抄而不是"按理解重写"
|
|
86
|
+
* 是有意的:上游改了这几行,diff 才看得出来我们跟着漂了没有。
|
|
87
|
+
* ============================================================ */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 携带身份语义的核心包:cordis 上下文与 dsh 服务在宿主/插件各持一份时
|
|
91
|
+
* Symbol 不等,注册对不上。schemastery、cosmokit 等纯工具库不在此列,
|
|
92
|
+
* 插件可以正常依赖。
|
|
93
|
+
*/
|
|
94
|
+
export const CORE_SCOPE = '@deepseek-ai/'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 是否为带身份语义的核心包(M3 / M7 共用)。
|
|
98
|
+
* 曾经 manifest-check 与 registry-check 各存一份,这里合并为一处。
|
|
99
|
+
* @param {string} name - npm 包名。
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function isIdentityCore(name) {
|
|
103
|
+
return name === `${CORE_SCOPE}cordis`
|
|
104
|
+
|| name.startsWith(`${CORE_SCOPE}cordis-`)
|
|
105
|
+
|| name.startsWith(`${CORE_SCOPE}dsh`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 是否与 DSH 运行时**同一条版本线**。
|
|
110
|
+
*
|
|
111
|
+
* 这条区分不能省:核心包里只有 @deepseek-ai/dsh* 跟着 DSH 运行时版本
|
|
112
|
+
* 走(0.1.3-alpha.1 这类),而 @deepseek-ai/cordis 自成一条 4.x 线 ——
|
|
113
|
+
* 拿 "4.0.1 || 4.0.2" 去比对运行时 0.1.3-alpha.1,得到的"不含目标运行时"
|
|
114
|
+
* 是范畴错误,而不是插件的问题(本包自己的清单就是这个形状)。
|
|
115
|
+
* 所以 M7 的**范围合法性**覆盖全部核心包,**是否含目标运行时**只对这条线判。
|
|
116
|
+
* @param {string} name - npm 包名。
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function isRuntimeVersioned(name) {
|
|
120
|
+
return name.startsWith(`${CORE_SCOPE}dsh`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 上游 dsh-community-market/src/install/service.ts:26 的 PACKAGE_NAME_PATTERN。 */
|
|
124
|
+
export const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 上游 desktop-plugins.ts:193 的 safePackageName(比市场侧多一条 214 长度限制)。
|
|
128
|
+
* @param {unknown} value
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function safePackageName(value) {
|
|
132
|
+
return typeof value === 'string' && value.length <= 214 && PACKAGE_NAME_PATTERN.test(value)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 永远装不进市场的产品包名。
|
|
137
|
+
* 上游 service.ts:35 BLOCKED_PRODUCT_PACKAGES + desktop-plugins.ts IMMUTABLE_BUNDLES
|
|
138
|
+
* 里对第三方有意义的那几个(宿主自身与市场自身)。
|
|
139
|
+
*/
|
|
140
|
+
export const BLOCKED_PACKAGE_NAMES = new Set([
|
|
141
|
+
'dsh-plugin-desktop',
|
|
142
|
+
'dsh-plugin-desktop-beta',
|
|
143
|
+
'dsh-community-market',
|
|
144
|
+
'@deepseek-ai/dsh-desktop-app',
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 上游 dsh-community-market/src/install/service.ts:268 safeBundlePatch,逐字照抄。
|
|
149
|
+
* @param {unknown} value - dsh.bundle.patch 的值。
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function safeBundlePatch(value) {
|
|
153
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 512 || value.includes('\0')) return false
|
|
154
|
+
const path = value.startsWith('./') ? value.slice(2) : value
|
|
155
|
+
return path.length > 0
|
|
156
|
+
&& !path.startsWith('/')
|
|
157
|
+
&& !path.includes('\\')
|
|
158
|
+
&& path.split('/').every(segment => segment.length > 0 && segment !== '.' && segment !== '..' && !segment.includes(':'))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* safeBundlePatch 不通过时,说清楚具体违反了哪一条 —— 只说"不合法"
|
|
163
|
+
* 作者无从下手。
|
|
164
|
+
* @param {string} value - 已确认为非空字符串的 patch 路径。
|
|
165
|
+
* @returns {string | undefined} 违规原因,合法时返回 undefined。
|
|
166
|
+
*/
|
|
167
|
+
export function bundlePatchProblem(value) {
|
|
168
|
+
if (safeBundlePatch(value)) return undefined
|
|
169
|
+
if (value.length > 512) return '超过 512 字符'
|
|
170
|
+
if (value.includes('\0')) return '含 NUL 字节'
|
|
171
|
+
const path = value.startsWith('./') ? value.slice(2) : value
|
|
172
|
+
if (path.length === 0) return '去掉 "./" 之后为空'
|
|
173
|
+
if (path.startsWith('/')) return '是绝对路径(必须相对于包根)'
|
|
174
|
+
if (path.includes('\\')) return '含反斜杠(必须用 "/" 分隔,Windows 风格路径不接受)'
|
|
175
|
+
const segments = path.split('/')
|
|
176
|
+
if (segments.some(segment => segment.length === 0)) return '含空路径段(如重复的 "//")'
|
|
177
|
+
if (segments.some(segment => segment === '.' || segment === '..')) return '含 "." 或 ".." 路径段(不得越出包根)'
|
|
178
|
+
if (segments.some(segment => segment.includes(':'))) return '路径段含 ":"'
|
|
179
|
+
return '不满足上游 safeBundlePatch'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 宿主保留的 Loader 行 id。撞上的后果不是"这个插件不生效":
|
|
184
|
+
* - settings / web-runtime:dsh-plugin-desktop 直接抛错,Desktop 起不来
|
|
185
|
+
* (profile.ts:955-958、977-979);
|
|
186
|
+
* - 市场行 id:该行被静默剥离,并让整个 Market provider 以
|
|
187
|
+
* "conflicting Market provider Loader identity was removed" 失败
|
|
188
|
+
* (profile.ts:704-750、913)。
|
|
189
|
+
*/
|
|
190
|
+
export const RESERVED_ROW_IDS = new Set([
|
|
191
|
+
'settings',
|
|
192
|
+
'web-runtime',
|
|
193
|
+
'desktop-webserver',
|
|
194
|
+
// DESKTOP_MARKET_IDENTITIES.{community,dshMarket}.rowId(desktop-market.ts:31,36)
|
|
195
|
+
'community-market',
|
|
196
|
+
'dsh-market',
|
|
197
|
+
])
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 宿主保留的 Loader 行 name(MARKET_PACKAGE_NAMES,desktop-market.ts:32,37)。
|
|
201
|
+
* 补丁行的 name 撞上同样触发 Market provider 失败;不过这类 name 本来就
|
|
202
|
+
* 会先被 M5 拦在"超出本包命名空间"上,这里保留是为了措辞更准确。
|
|
203
|
+
*/
|
|
204
|
+
export const RESERVED_ROW_NAMES = new Set([
|
|
205
|
+
'dsh-community-market',
|
|
206
|
+
'dshmarket',
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
/** 宿主 dsh-plugin-desktop 2.0.5 的 engines.node。 */
|
|
210
|
+
export const HOST_NODE_ENGINES = '^22.19.0 || >=24.0.0'
|