dsh-server-connector 0.2.0 → 0.2.2

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/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.2",
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",