@bushizhanghan/dsh-publish-gate 0.7.1 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1
4
+
5
+ - `publish_gate_verify` gained `waitMs` (capped at 90 s) and reports `waitedMs`. npm answers 202 and keeps
6
+ the version invisible for 60–180 s after a publish, so the single-shot check reported "not on the registry"
7
+ for releases that had already succeeded — it happened on this plugin own 0.8.0 publish, which stayed
8
+ invisible for over three minutes. `waitForPublished()` polls until the shasum appears; `waitMs: 0` keeps
9
+ the old single-shot behaviour, and a real mismatch stops the poll immediately.
10
+ ## 0.8.0
11
+
12
+ - `tool-input-throws` (error): `publish_gate_tools` gained `fuzz` and `skip`. With `fuzz: true` it drives
13
+ every declared parameter with a wrong-typed value and reports the tools that throw instead of answering.
14
+ The host never validates a call against `parameters`, so those values really arrive — this rule exists
15
+ because the same sweep found 12 real crashes across this workspace's own plugins (a number reaching
16
+ `path.join` via `session`, `profile`, `casePath` and `workspace`), all of which handed the caller a raw
17
+ Node TypeError. Only a throw counts: a filter that ignores a number and returns the unfiltered list still
18
+ answers, and every optional boolean treats any truthy value the same way.
19
+ - One finding per tool+parameter, not one per wrong value.
3
20
  ## 0.7.1
4
21
 
5
22
  - Fixed: a non-string `dir` made every path tool throw a raw Node `TypeError`
package/README.md CHANGED
@@ -106,6 +106,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
106
106
  | `tool-bad-parameter-type` | `parameters` 里某个 `type` 写错(`"strin"`)。宿主把 `parameters` 渲染进 prompt、但**从不校验**调用值,所以这个 typo 只是悄悄把类型提示弄丢 |
107
107
  | `tool-undeclared-param` | `execute` 读了 `args.x`,但 `parameters.properties` 里没有 `x`。模型只能传 schema 声明过的参数,所以这个值在真实调用里永远是 `undefined`——代码在,永远不跑,也不报错。`args.x`、`args['x']`、`const { x } = args` 三种写法都认 |
108
108
  | `tool-output-invalid` | 工具返回的东西**自己的 output schema 不收**。宿主每个结果都拿 schema 校验,不匹配就抛 `ToolOutputError`——工具从第一次调用起就不可用,而报错只给一个 JSON 路径,不给是哪行代码返回的。只有点名 `call` 真的调了才检查。这个插件自己踩过:schema 收紧成 `additionalProperties: false`,但有一条错误分支还在返回 `{ ok, error }` |
109
+ | `tool-input-throws` | 把每个声明过的参数**塞一个错类型的值**去调,工具直接抛 `TypeError` 而不是回答。宿主从不拿 `parameters` 校验入参,所以这个值真的会到你的代码里——调用方拿到的是 `The "path" argument must be of type string`,插件日志里没有一行说是哪个工具。这条规则是在自己的工作区里跑出 **12 个真实崩溃** 之后才加的(`session`/`profile`/`casePath`/`workspace` 各一个入口),现在九个插件全绿。默认关:它会把每个工具都调一遍,用 `skip` 点名排除那些会开程序、写文件的 |
109
110
 
110
111
  ### 浏览器半边:侧栏面板到底会不会出现
111
112
 
@@ -214,7 +215,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
214
215
  ## 开发
215
216
 
216
217
  ```sh
217
- node --test # 88 个引擎单测
218
+ node --test # 92 个引擎单测
218
219
  node test/integration.test.mjs # 27 条集成断言(9 个真实插件作 fixture)
219
220
  ```
220
221
 
package/lib/engine.js CHANGED
@@ -1198,6 +1198,7 @@ ctx.tools = {
1198
1198
  hasExecute: typeof def?.execute === 'function',
1199
1199
  hasOutputSchema: Boolean(def && def.output && def.output.schema),
1200
1200
  schema: (def && def.output && def.output.schema) || null,
1201
+ parameters: (def && def.parameters) || null,
1201
1202
  cardKind: card,
1202
1203
  undeclaredParams: undeclaredArgs(def),
1203
1204
  run: typeof def?.execute === 'function' ? def.execute : null,
@@ -1278,7 +1279,45 @@ try {
1278
1279
  calls.push({ name: entry.name, ok: false, timedOut: message === 'timed out', error: message })
1279
1280
  }
1280
1281
  }
1281
- emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls })
1282
+ // The host never validates tool input against \`parameters\`, so a number really
1283
+ // does reach a path argument. Fuzzing every declared parameter with the wrong
1284
+ // type found 12 crashes across this workspace's own plugins — each one a raw
1285
+ // TypeError handed to the caller instead of an answer.
1286
+ stage = 'fuzz'
1287
+ const fuzzIssues = []
1288
+ if (process.env.PG_FUZZ === '1') {
1289
+ const skip = new Set(JSON.parse(process.env.PG_FUZZ_SKIP || '[]'))
1290
+ const WRONG = {
1291
+ string: [123, true, { a: 1 }, [1]],
1292
+ boolean: ['yes', 1, {}],
1293
+ number: ['n', {}, []],
1294
+ array: ['x', 1, {}],
1295
+ object: ['x', 1, []],
1296
+ }
1297
+ for (const entry of registered) {
1298
+ if (!entry.run || skip.has(entry.name)) continue
1299
+ const props = entry.parameters && entry.parameters.properties
1300
+ if (!props || typeof props !== 'object') continue
1301
+ for (const [param, spec] of Object.entries(props)) {
1302
+ for (const bad of WRONG[spec && spec.type] || [123]) {
1303
+ try {
1304
+ await Promise.race([
1305
+ Promise.resolve(entry.run({ [param]: bad }, { signal: undefined })),
1306
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), budget)),
1307
+ ])
1308
+ } catch (error) {
1309
+ // Only a throw is a defect. A filter parameter that ignores a number
1310
+ // and returns the unfiltered list still answers, and every optional
1311
+ // boolean treats any truthy value the same way.
1312
+ const message = String((error && error.message) || error)
1313
+ if (message === 'timed out') continue
1314
+ fuzzIssues.push({ tool: entry.name, param, value: JSON.stringify(bad), problem: message.slice(0, 200) })
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+ emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls, fuzzIssues })
1282
1321
  } catch (error) {
1283
1322
  emit({ ok: false, stage, error: String((error && error.message) || error) })
1284
1323
  }
@@ -1297,6 +1336,8 @@ export async function checkToolRegistrations(dir, opts = {}) {
1297
1336
  ...process.env,
1298
1337
  PG_CALLS: JSON.stringify(Array.isArray(opts.calls) ? opts.calls : []),
1299
1338
  PG_CALL_MS: String(opts.callTimeoutMs ?? 8000),
1339
+ PG_FUZZ: opts.fuzz ? '1' : '0',
1340
+ PG_FUZZ_SKIP: JSON.stringify(Array.isArray(opts.fuzzSkip) ? opts.fuzzSkip : []),
1300
1341
  },
1301
1342
  })
1302
1343
  const line = String(stdout).split('\n').filter((l) => l.startsWith('@@PG@@')).pop()
@@ -1428,6 +1469,23 @@ export async function checkToolRegistrations(dir, opts = {}) {
1428
1469
  ),
1429
1470
  )
1430
1471
  }
1472
+ // One finding per tool+parameter: the first wrong value is enough to prove the
1473
+ // guard is missing, and four findings for the same line is noise.
1474
+ const fuzzSeen = new Set()
1475
+ for (const issue of Array.isArray(payload.fuzzIssues) ? payload.fuzzIssues : []) {
1476
+ const key = `${issue.tool}\u0000${issue.param}`
1477
+ if (fuzzSeen.has(key)) continue
1478
+ fuzzSeen.add(key)
1479
+ out.push(
1480
+ F(
1481
+ 'tool-input-throws',
1482
+ 'error',
1483
+ `${issue.tool}(${issue.param}: ${issue.value}) — ${issue.problem}`,
1484
+ 'The host never validates a call against parameters, so this really reaches your code with the wrong type. The caller gets a raw Node TypeError instead of an answer, and nothing in the plugin log says which tool was at fault.',
1485
+ `Guard the top of ${issue.tool}: if (typeof args.${issue.param} !== 'string') return { ok: false, error: '${issue.param} must be a string' }.`,
1486
+ ),
1487
+ )
1488
+ }
1431
1489
  return { findings: out, tools }
1432
1490
  }
1433
1491
 
@@ -1858,6 +1916,29 @@ export async function checkPublishedMatches(name, version, localPack, { registry
1858
1916
  return out
1859
1917
  }
1860
1918
 
1919
+ /**
1920
+ * Poll the registry until the published bytes show up.
1921
+ *
1922
+ * npm answers 202 and keeps the version invisible for 60–180 s, so a single
1923
+ * shot right after `npm publish` reports "not on the registry" for a release
1924
+ * that already succeeded — the most common false alarm this plugin produces,
1925
+ * including on its own publishes. `probe` is injectable so the loop tests
1926
+ * without the network.
1927
+ */
1928
+ export async function waitForPublished(name, version, localPack, opts = {}) {
1929
+ const waitMs = Math.max(0, Number(opts.waitMs) || 0)
1930
+ const intervalMs = Math.max(1000, Number(opts.intervalMs) || 10_000)
1931
+ const probe = opts.probe || ((o) => checkPublishedMatches(name, version, localPack, o))
1932
+ const net = { registry: opts.registry, timeoutMs: opts.timeoutMs }
1933
+ const started = Date.now()
1934
+ let findings = await probe(net)
1935
+ while (!findings.some((f) => f.rule === 'publish-served') && Date.now() - started < waitMs) {
1936
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, waitMs - (Date.now() - started))))
1937
+ findings = await probe(net)
1938
+ }
1939
+ return { findings, waitedMs: Date.now() - started }
1940
+ }
1941
+
1861
1942
  /** The async half of the gate: everything that needs a subprocess or the network. */
1862
1943
  export async function runArtifactChecks(dir, opts = {}) {
1863
1944
  const packResult = opts.skipPack ? { findings: [], pack: null } : await checkPack(dir, opts)
@@ -2104,7 +2185,7 @@ const RULE_ORDER = [
2104
2185
  'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
2105
2186
  'inject-unused-service',
2106
2187
  'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
2107
- 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-call-throws', 'tool-output-invalid', 'tool-call-slow',
2188
+ 'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-input-throws', 'tool-call-throws', 'tool-output-invalid', 'tool-call-slow',
2108
2189
  'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
2109
2190
  'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
2110
2191
  'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
package/lib/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  checkClientRuntime,
25
25
  checkPublishIdentity,
26
26
  checkVersionTaken,
27
+ waitForPublished,
27
28
  compareVersions,
28
29
  latestPublished,
29
30
  renderReport,
@@ -132,7 +133,7 @@ export function apply(ctx, config = {}) {
132
133
  {
133
134
  name: 'publish_gate_tools',
134
135
  description:
135
- 'Import a plugin entry in an isolated child process, call apply(ctx) with a no-op context, and report what it actually registered. Catches the failures the manifest cannot show: the entry throws when imported, apply throws, a tool has no execute function, two tools share a name, presentCall throws, and a card kind the host does not render (the host only knows other/execute/search/read/edit/delete/fetch — anything else registers fine and renders a broken card). 20 s timeout, plugin code never runs in the host process. Returns {ok, tools:[{name,hasExecute,hasOutputSchema,cardKind}], findings, report}.',
136
+ 'Import a plugin entry in an isolated child process, call apply(ctx) with a no-op context, and report what it actually registered. Catches the failures the manifest cannot show: the entry throws when imported, apply throws, a tool has no execute function, two tools share a name, presentCall throws, and a card kind the host does not render (the host only knows other/execute/search/read/edit/delete/fetch — anything else registers fine and renders a broken card). Optional deeper modes: `call` runs named tools with {} and validates each result against its own output schema; `fuzz` drives every declared parameter with a wrong-typed value and reports the tools that throw a raw TypeError instead of answering — the host never validates a call against parameters, so those values really arrive. 20 s timeout, plugin code never runs in the host process. Returns {ok, tools:[{name,hasExecute,hasOutputSchema,cardKind}], findings, report}.',
136
137
  parameters: {
137
138
  type: 'object',
138
139
  properties: {
@@ -144,12 +145,27 @@ export function apply(ctx, config = {}) {
144
145
  description:
145
146
  'Tool names to actually call with empty arguments, e.g. ["page_watch_list"]. Off by default: some tools open applications or write files, so you name the ones you consider safe. A tool that throws on {} is a bug no other check can see, because registration never runs execute.',
146
147
  },
148
+ fuzz: {
149
+ type: 'boolean',
150
+ description:
151
+ 'Drive every declared parameter with wrong-typed values (a number where a string is declared, and so on) and report the tools that throw instead of answering. The host never validates a call against parameters, so these values really reach your code. Off by default because it calls every tool — pass skip for the ones that launch applications or write files.',
152
+ },
153
+ skip: {
154
+ type: 'array',
155
+ items: { type: 'string' },
156
+ description: 'Tool names the fuzz must not call, e.g. ["weapp_open","of_run","page_watch_add"].',
157
+ },
147
158
  },
148
159
  },
149
160
  async execute(args) {
150
161
  const dir = resolveDir(args.dir)
151
162
  if (!dir) return { ok: false, error: 'no plugin directory given (dir must be a string path)' }
152
- const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs, calls: args.call })
163
+ const { findings, tools } = await checkToolRegistrations(dir, {
164
+ timeoutMs: args.timeoutMs,
165
+ calls: args.call,
166
+ fuzz: args.fuzz,
167
+ fuzzSkip: args.skip,
168
+ })
153
169
  return { ok: true, tools, findings, report: renderReport(findings, { title: `runtime gate · ${dir}` }) }
154
170
  },
155
171
  },
@@ -174,12 +190,17 @@ export function apply(ctx, config = {}) {
174
190
  {
175
191
  name: 'publish_gate_verify',
176
192
  description:
177
- 'Check that the tarball on the npm registry is the one in this directory. Runs `npm pack --dry-run` and compares its shasum with the registry dist.shasum — an exact comparison, not a version-number guess, so it catches "I bumped and forgot to publish" and "I published, then edited lib/". Reports publish-served (match), publish-served-mismatch (the version is spent and the registry holds different bytes), or publish-not-served (not published, or still inside the registry\'s one-minute 202-then-404 window). Run it after `npm publish`. Returns {ok, name, version, published, shasumMatch, localShasum, unpackedSize, files, findings, report}.',
193
+ 'Check that the tarball on the npm registry is the one in this directory. Runs `npm pack --dry-run` and compares its shasum with the registry dist.shasum — an exact comparison, not a version-number guess, so it catches "I bumped and forgot to publish" and "I published, then edited lib/". Reports publish-served (match), publish-served-mismatch (the version is spent and the registry holds different bytes), or publish-not-served (not published, or still inside the registry\'s 202-then-404 window — pass waitMs to poll through that window instead of concluding too early). Run it right after `npm publish`. Returns {ok, name, version, published, shasumMatch, localShasum, unpackedSize, files, waitedMs, findings, report}.',
178
194
  parameters: {
179
195
  type: 'object',
180
196
  properties: {
181
197
  dir: { type: 'string', description: 'Plugin directory.' },
182
198
  version: { type: 'string', description: 'Version to verify (defaults to package.json version).' },
199
+ waitMs: {
200
+ type: 'number',
201
+ description:
202
+ 'Poll until the registry serves the bytes, up to this many milliseconds (capped at 90000). npm returns 202 and keeps the version invisible for 60-180 s after a publish, so a single shot reports "not published" on a release that already succeeded.',
203
+ },
183
204
  },
184
205
  },
185
206
  async execute(args) {
@@ -190,7 +211,12 @@ export function apply(ctx, config = {}) {
190
211
  const version = args.version || json.version
191
212
  const { pack, findings: packFindings } = await checkPack(dir)
192
213
  if (!pack) return { ok: false, error: `npm pack --dry-run did not run, so there is nothing to compare: ${packFindings.map((f) => f.title).join('; ')}` }
193
- const findings = await checkPublishedMatches(json.name, version, pack, { registry: cfg.registry, timeoutMs: cfg.networkTimeoutMs })
214
+ const waitMs = Math.min(Math.max(0, Number(args.waitMs) || 0), 90_000)
215
+ const { findings, waitedMs } = await waitForPublished(json.name, version, pack, {
216
+ registry: cfg.registry,
217
+ timeoutMs: cfg.networkTimeoutMs,
218
+ waitMs,
219
+ })
194
220
  const served = findings.some((f) => f.rule === 'publish-served')
195
221
  return {
196
222
  ok: true,
@@ -201,6 +227,7 @@ export function apply(ctx, config = {}) {
201
227
  localShasum: pack.shasum,
202
228
  unpackedSize: pack.unpackedSize,
203
229
  files: (pack.files || []).length,
230
+ waitedMs,
204
231
  findings,
205
232
  report: renderReport(findings, { title: `publish verify · ${json.name}@${version}` }),
206
233
  }
package/package.json CHANGED
@@ -1,52 +1,52 @@
1
- {
2
- "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.7.1",
4
- "description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness. 发布前的七层门禁。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./engine": "./lib/engine.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "lib",
14
- "cordis.patch.yml",
15
- "README.md",
16
- "LICENSE",
17
- "CHANGELOG.md"
18
- ],
19
- "keywords": [
20
- "dsh",
21
- "dsh-plugin",
22
- "deepseek-harness",
23
- "cordis",
24
- "cordis-plugin",
25
- "publish",
26
- "preflight",
27
- "npm",
28
- "market",
29
- "agent-tools"
30
- ],
31
- "author": "bushizhanghan",
32
- "license": "MIT",
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/bushizhanghan/dsh-publish-gate.git"
36
- },
37
- "bugs": {
38
- "url": "https://github.com/bushizhanghan/dsh-publish-gate/issues"
39
- },
40
- "homepage": "https://github.com/bushizhanghan/dsh-publish-gate#readme",
41
- "engines": {
42
- "node": ">=22"
43
- },
44
- "dsh": {
45
- "bundle": {
46
- "patch": "./cordis.patch.yml"
47
- }
48
- },
49
- "scripts": {
50
- "test": "node --test"
51
- }
52
- }
1
+ {
2
+ "name": "@bushizhanghan/dsh-publish-gate",
3
+ "version": "0.8.1",
4
+ "description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness — plus an input fuzzer that drives every declared parameter with the wrong type. 发布前的七层门禁 + 入参模糊测试。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./engine": "./lib/engine.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "deepseek-harness",
23
+ "cordis",
24
+ "cordis-plugin",
25
+ "publish",
26
+ "preflight",
27
+ "npm",
28
+ "market",
29
+ "agent-tools"
30
+ ],
31
+ "author": "bushizhanghan",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/bushizhanghan/dsh-publish-gate.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/bushizhanghan/dsh-publish-gate/issues"
39
+ },
40
+ "homepage": "https://github.com/bushizhanghan/dsh-publish-gate#readme",
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ }
48
+ },
49
+ "scripts": {
50
+ "test": "node --test"
51
+ }
52
+ }