dsh-server-connector 0.2.0 → 0.2.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,45 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.2.1 — 2026-11-06
4
+
5
+ ### 修复(严重:导致 DSH 无法启动)
6
+
7
+ - **密码凭证引用格式非法,会让整个 DSH 启动失败**。此前密码认证生成
8
+ `server-connector:<id>:password`,其中含冒号与连字符,而
9
+ `@deepseek-ai/dsh-credentials` 要求所有凭证引用匹配 `/^[A-Za-z_][A-Za-z0-9_]*$/`。
10
+ 这条非法引用一旦写入 `servers.json`,下次启动时 `credentials` 插件解析失败;
11
+ 由于它是必需插件,整个 Host 起不来——**而且禁用本插件也没用,因为坏值已经在磁盘上**。
12
+
13
+ 三处修复,缺一不可:
14
+ - 改为生成 `DSH_SERVER_CONNECTOR_<ID>_PASSWORD`,并校验调用方传入的引用,
15
+ 使本插件再也无法写入非法值;
16
+ - 加载时迁移**旧格式**,让已经落盘的记录继续可用,而不是把「Host 崩溃」换成
17
+ 「插件加载失败」;
18
+ - 迁移后**回写文件**,否则之后复制的文件副本仍带着非法值。已经合法的文件不会被重写。
19
+
20
+ 只迁移本插件曾经写出的那一种格式;其他非法引用仍然拒绝,因为静默改写可能让记录
21
+ 指向另一个密钥。
22
+
23
+ - 同时修正 README 与设计文档中展示的引用格式(它们此前在教读者写出非法值)。
24
+
25
+ ### 升级说明
26
+
27
+ 升级到 0.2.1 后**无需手动改文件**:插件加载时会自动把旧引用迁移成合法值并回写。
28
+
29
+ 若 DSH 当前因该问题无法启动,先做下面任一操作再启动:
30
+
31
+ - 编辑 `~/.dsh/server-connector/servers.json`,把
32
+ `"server-connector:srv-01:password"` 改成 `"DSH_SERVER_CONNECTOR_SRV_01_PASSWORD"`
33
+ (`srv-02` 同理);或
34
+ - 直接删除该文件中 `auth.kind` 为 `password` 的服务器记录。
35
+
36
+ 然后安装 0.2.1。
37
+
38
+ > 迁移只改引用名,**不搬运密钥值**。升级后原密码可能需要重新输入一次
39
+ > (取决于该密钥此前是否成功写入 `~/.dsh/.credentials.yaml`)。
40
+
41
+ ---
42
+
3
43
  ## 0.2.0 — 2026-11-05
4
44
 
5
45
  ### 新增
package/README.md CHANGED
@@ -506,7 +506,8 @@ $DSH_HOME/server-connector/
506
506
 
507
507
  ### 凭据
508
508
 
509
- - 密码交给 DSH 凭据服务(`$DSH_HOME/.credentials.yaml`),`servers.json` 只存引用,如 `server-connector:srv-03:password`;
509
+ - 密码交给 DSH 凭据服务(`$DSH_HOME/.credentials.yaml`),`servers.json` 只存引用,如 `DSH_SERVER_CONNECTOR_SRV_03_PASSWORD`
510
+ (必须是 POSIX 标识符,不能含冒号或连字符);
510
511
  - 面板只显示「已设置 / 未设置」,不会回显明文;接口也不会返回密钥;
511
512
  - 没有凭据服务时,密码认证会报 `NO_CREDENTIAL`,改用私钥认证即可;
512
513
  - 私钥认证只记录**路径**,密钥内容由 `ssh2` 自己读,插件不缓存。
package/lib/registry.js CHANGED
@@ -336,13 +336,92 @@ function normalizeOptionalTimestamp(value, field, errorCode) {
336
336
  return normalizeTimestamp(value, field, errorCode)
337
337
  }
338
338
 
339
+ /**
340
+ * The grammar every credential ref must satisfy: a POSIX shell identifier.
341
+ *
342
+ * This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
343
+ * invention of this plugin. Keeping a local copy is deliberate: a ref this
344
+ * plugin persists is later parsed by that package, and a mismatch is not a
345
+ * local error — it prevents `credentials` from activating, which fails the whole
346
+ * Host startup.
347
+ */
348
+ const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
349
+
350
+ /**
351
+ * The ref this plugin derives for a server's password (doc §6.3).
352
+ *
353
+ * `server-connector:<id>:password` was the old shape and it was illegal: the
354
+ * colon and the dash fall outside the grammar above, so persisting it broke the
355
+ * next startup. The id is folded into a single identifier instead
356
+ * (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
357
+ * @param {string} serverId - e.g. `srv-01`
358
+ * @returns {string}
359
+ */
360
+ function deriveCredentialRef(serverId) {
361
+ const normalised = String(serverId)
362
+ .toUpperCase()
363
+ .replace(/[^A-Z0-9]+/g, '_')
364
+ .replace(/^_+|_+$/g, '')
365
+ const safe = normalised === '' ? 'SERVER' : normalised
366
+ return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
367
+ }
368
+
369
+ /**
370
+ * Validate a caller-supplied credential ref against the same grammar.
371
+ *
372
+ * A caller may pass its own ref (an environment-variable name, typically), but
373
+ * an illegal one must be refused here rather than stored: storing it would
374
+ * break the next startup instead of this request.
375
+ * @param {unknown} value
376
+ * @param {RegistryErrorCode} errorCode
377
+ * @returns {string}
378
+ */
379
+ function assertCredentialRef(value, errorCode) {
380
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
381
+ if (!CREDENTIAL_REF_PATTERN.test(ref)) {
382
+ throw new RegistryError(
383
+ errorCode,
384
+ `auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
385
+ `(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
386
+ `any other ref and the rejection fails the whole startup.`,
387
+ )
388
+ }
389
+ return ref
390
+ }
391
+
392
+ /**
393
+ * Read-time recovery for a ref an older build persisted (doc §6.3).
394
+ *
395
+ * Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
396
+ * outside the grammar. Those records exist on disk in the wild, and refusing
397
+ * them at load would leave the plugin unable to start — trading a Host crash for
398
+ * a plugin that never loads. Instead the illegal legacy shape is mapped onto the
399
+ * derived one, so the record keeps working and the next write persists the fixed
400
+ * ref.
401
+ *
402
+ * Only the exact legacy shape is migrated. Any other illegal ref is still
403
+ * refused: silently rewriting a ref this plugin never wrote could point at a
404
+ * different secret.
405
+ * @param {unknown} value
406
+ * @param {string} serverId
407
+ * @param {RegistryErrorCode} errorCode
408
+ * @returns {string}
409
+ */
410
+ function migrateCredentialRef(value, serverId, errorCode) {
411
+ const ref = requiredString(value, 'auth.credentialRef', errorCode)
412
+ if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
413
+ if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
414
+ return assertCredentialRef(ref, errorCode)
415
+ }
416
+
339
417
  /**
340
418
  * @param {unknown} value
341
419
  * @param {string} serverId
342
420
  * @param {RegistryErrorCode} errorCode
421
+ * @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
343
422
  * @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
344
423
  */
345
- function normalizeAuth(value, serverId, errorCode) {
424
+ function normalizeAuth(value, serverId, errorCode, options = {}) {
346
425
  if (value === undefined) return { kind: 'agent' }
347
426
  if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
348
427
  const kind = value.kind
@@ -352,12 +431,20 @@ function normalizeAuth(value, serverId, errorCode) {
352
431
  }
353
432
  if (kind === 'password') {
354
433
  assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
355
- // Doc §6.3: the record keeps a reference, never the password itself. The
356
- // conventional ref is `server-connector:<id>:password`.
434
+ // Doc §6.3: the record keeps a reference, never the password itself.
435
+ //
436
+ // The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
437
+ // validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
438
+ // credentials.yaml, and a ref outside that grammar throws during
439
+ // `credentials` activation. Because `credentials` is a required plugin, that
440
+ // takes the entire Host down at startup — a persisted bad ref bricks DSH
441
+ // until the file is edited by hand. Derive and validate accordingly.
357
442
  const credentialRef =
358
443
  value.credentialRef === undefined
359
- ? `server-connector:${serverId}:password`
360
- : requiredString(value.credentialRef, 'auth.credentialRef', errorCode)
444
+ ? deriveCredentialRef(serverId)
445
+ : options.migrate === true
446
+ ? migrateCredentialRef(value.credentialRef, serverId, errorCode)
447
+ : assertCredentialRef(value.credentialRef, errorCode)
361
448
  return { kind: 'password', credentialRef }
362
449
  }
363
450
  if (kind === 'agent') {
@@ -455,6 +542,7 @@ function normalizeCustomRules(value, errorCode) {
455
542
  * errorCode: RegistryErrorCode,
456
543
  * label: string,
457
544
  * createdAt?: string,
545
+ * migrate?: boolean,
458
546
  * }} options
459
547
  * @returns {ServerRecord}
460
548
  */
@@ -472,7 +560,7 @@ function normalizeRecord(source, options) {
472
560
  host: normalizeHost(source.host, errorCode),
473
561
  port: normalizePort(source.port, errorCode),
474
562
  username: requiredString(source.username, 'username', errorCode),
475
- auth: normalizeAuth(source.auth, id, errorCode),
563
+ auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
476
564
  hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
477
565
  defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
478
566
  enabled: normalizeEnabled(source.enabled, errorCode),
@@ -786,6 +874,9 @@ export class Registry {
786
874
  id: recordId,
787
875
  errorCode: 'INVALID_REGISTRY',
788
876
  label: `servers[${index}]`,
877
+ // Loading is the one path allowed to repair a legacy credential ref;
878
+ // writes are strict so this plugin can never persist a bad one again.
879
+ migrate: true,
789
880
  })
790
881
  if (seenIds.has(record.id)) {
791
882
  throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
@@ -798,7 +889,32 @@ export class Registry {
798
889
  seenNames.add(nameKey)
799
890
  servers.push(record)
800
891
  }
801
- return { version: REGISTRY_VERSION, servers }
892
+ const document = { version: REGISTRY_VERSION, servers }
893
+ // Persist a repaired legacy ref immediately. Without this the migration
894
+ // lives only in memory: every later read re-runs it, and a copy of the file
895
+ // taken before the first write would still carry the illegal ref that breaks
896
+ // the next startup. The rewrite is conditional so a clean file is never
897
+ // touched (its mtime stays put).
898
+ if (this.#migrated(text, document)) await this.#save(document)
899
+ return document
900
+ }
901
+
902
+ /**
903
+ * Whether the loaded document differs from what is on disk, i.e. whether the
904
+ * legacy-ref migration actually changed a record and the file must be rewritten.
905
+ * @param {string} text - the raw file text that was parsed.
906
+ * @param {RegistryDocument} document - the normalised document.
907
+ * @returns {boolean}
908
+ */
909
+ #migrated(text, document) {
910
+ for (const record of document.servers) {
911
+ if (record.auth.kind !== 'password') continue
912
+ const ref = record.auth.credentialRef
913
+ // The normalised ref is legal by construction; if the raw text does not
914
+ // contain it, this record was migrated.
915
+ if (!text.includes(JSON.stringify(ref))) return true
916
+ }
917
+ return false
802
918
  }
803
919
 
804
920
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-server-connector",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DSH plugin: SSH execution for servers configured on this machine, with a built-in high-risk command policy (block / confirm / allow).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",