dsh-server-connector 0.2.3 → 0.3.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/CHANGELOG.md +72 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1728
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/registry.js
CHANGED
|
@@ -1,968 +1,968 @@
|
|
|
1
|
-
// dsh-server-connector — server registry (design doc §3.1/§3.5).
|
|
2
|
-
//
|
|
3
|
-
// The registry owns exactly one thing: CRUD over server records in
|
|
4
|
-
// `$DSH_HOME/server-connector/servers.json`. It knows nothing about SSH,
|
|
5
|
-
// policy evaluation or DSH; `lib/tools.js` (a later phase) composes it.
|
|
6
|
-
//
|
|
7
|
-
// Guarantees:
|
|
8
|
-
// - every mutation validates a complete record before anything is written;
|
|
9
|
-
// - the write is an atomic replacement (temp file in the same directory +
|
|
10
|
-
// rename), so a crash never leaves a half-written JSON document;
|
|
11
|
-
// - mutations are serialised per instance, so two concurrent create() calls
|
|
12
|
-
// cannot read the same document and silently lose one server;
|
|
13
|
-
// - secrets are never persisted: password auth keeps only a `credentialRef`.
|
|
14
|
-
//
|
|
15
|
-
// File layout (doc §3.5):
|
|
16
|
-
// $DSH_HOME/server-connector/servers.json
|
|
17
|
-
// { "version": 1, "servers": [ ... ] }
|
|
18
|
-
|
|
19
|
-
import { randomBytes } from 'node:crypto'
|
|
20
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
21
|
-
import { homedir } from 'node:os'
|
|
22
|
-
import { dirname, join, resolve } from 'node:path'
|
|
23
|
-
|
|
24
|
-
import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
|
|
25
|
-
|
|
26
|
-
/** Schema version written to, and required from, `servers.json`. */
|
|
27
|
-
export const REGISTRY_VERSION = 1
|
|
28
|
-
|
|
29
|
-
/** Directory below `$DSH_HOME` that holds every file of this plugin (doc §3.5). */
|
|
30
|
-
export const CONNECTOR_DIR_NAME = 'server-connector'
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* One tail promise per registry file, so every Registry instance in this
|
|
34
|
-
* process serialises its mutations against the same document.
|
|
35
|
-
* @type {Map<string, Promise<void>>}
|
|
36
|
-
*/
|
|
37
|
-
const MUTATION_QUEUES = new Map()
|
|
38
|
-
|
|
39
|
-
/** File name of the server registry. */
|
|
40
|
-
export const SERVERS_FILE_NAME = 'servers.json'
|
|
41
|
-
|
|
42
|
-
/** The three auth shapes of doc §3.1. */
|
|
43
|
-
export const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
|
|
44
|
-
|
|
45
|
-
/** The three host-key verification modes of doc §6.4. */
|
|
46
|
-
export const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
|
|
47
|
-
|
|
48
|
-
/** The three per-server policy levels of doc §4.2. */
|
|
49
|
-
export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* One server record exactly as persisted (doc §3.1).
|
|
53
|
-
* @typedef {object} ServerRecord
|
|
54
|
-
* @property {string} id - stable addressing key; names may change, ids must not.
|
|
55
|
-
* @property {string} name - unique display name.
|
|
56
|
-
* @property {string} host
|
|
57
|
-
* @property {number} port
|
|
58
|
-
* @property {string} username
|
|
59
|
-
* @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
|
|
60
|
-
* @property {'accept-new'|'verify'|'off'} hostKeyMode
|
|
61
|
-
* @property {string|null} defaultWorkDir
|
|
62
|
-
* @property {boolean} enabled
|
|
63
|
-
* @property {Record<string, 'allow'|'confirm'|'block'>} policy
|
|
64
|
-
* @property {string[]} disabledRules
|
|
65
|
-
* @property {import('./policy.js').CustomRule[]} customRules
|
|
66
|
-
* @property {string} createdAt
|
|
67
|
-
* @property {string|null} lastConnectedAt
|
|
68
|
-
*/
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The parsed file: a schema version plus the records.
|
|
72
|
-
* @typedef {object} RegistryDocument
|
|
73
|
-
* @property {number} version
|
|
74
|
-
* @property {ServerRecord[]} servers
|
|
75
|
-
*/
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Machine-readable registry failure. `UNKNOWN_SERVER` and `AMBIGUOUS_SERVER`
|
|
79
|
-
* are the tool-facing codes of doc §5.5; the rest describe input or storage
|
|
80
|
-
* problems. The message is written for both the model and the panel: unknown
|
|
81
|
-
* servers list every configured server, ambiguous ones list the candidates, so
|
|
82
|
-
* a caller can self-correct in one turn (doc §5.2).
|
|
83
|
-
* @typedef {'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'DUPLICATE_NAME'|'INVALID_SERVER'|'INVALID_REGISTRY'} RegistryErrorCode
|
|
84
|
-
*/
|
|
85
|
-
export class RegistryError extends Error {
|
|
86
|
-
/**
|
|
87
|
-
* @param {RegistryErrorCode} code
|
|
88
|
-
* @param {string} message
|
|
89
|
-
* @param {{available?: string[], candidates?: string[]}} [details]
|
|
90
|
-
*/
|
|
91
|
-
constructor(code, message, details = {}) {
|
|
92
|
-
super(message)
|
|
93
|
-
this.name = 'RegistryError'
|
|
94
|
-
/** @type {RegistryErrorCode} */
|
|
95
|
-
this.code = code
|
|
96
|
-
/** @type {string[]|undefined} */
|
|
97
|
-
this.available = details.available
|
|
98
|
-
/** @type {string[]|undefined} */
|
|
99
|
-
this.candidates = details.candidates
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The harness home: `$DSH_HOME` when set, otherwise `~/.dsh` (the same default
|
|
105
|
-
* the harness itself uses).
|
|
106
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
107
|
-
* @returns {string}
|
|
108
|
-
*/
|
|
109
|
-
export function dshHome(env = process.env) {
|
|
110
|
-
const configured = env.DSH_HOME
|
|
111
|
-
return configured !== undefined && configured.trim() !== '' ? configured.trim() : join(homedir(), '.dsh')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The plugin's data directory below the harness home.
|
|
116
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
117
|
-
* @returns {string}
|
|
118
|
-
*/
|
|
119
|
-
export function connectorDirectory(env = process.env) {
|
|
120
|
-
return join(dshHome(env), CONNECTOR_DIR_NAME)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Default location of `servers.json` (doc §3.5).
|
|
125
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
126
|
-
* @returns {string}
|
|
127
|
-
*/
|
|
128
|
-
export function defaultServersFile(env = process.env) {
|
|
129
|
-
return join(connectorDirectory(env), SERVERS_FILE_NAME)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Fields a create() call accepts. */
|
|
133
|
-
const CREATE_FIELDS = new Set([
|
|
134
|
-
'name',
|
|
135
|
-
'host',
|
|
136
|
-
'port',
|
|
137
|
-
'username',
|
|
138
|
-
'auth',
|
|
139
|
-
'hostKeyMode',
|
|
140
|
-
'enabled',
|
|
141
|
-
'defaultWorkDir',
|
|
142
|
-
'policy',
|
|
143
|
-
'disabledRules',
|
|
144
|
-
'customRules',
|
|
145
|
-
])
|
|
146
|
-
|
|
147
|
-
/** Fields an update() call accepts; `id` and `createdAt` are immutable. */
|
|
148
|
-
const UPDATE_FIELDS = new Set([...CREATE_FIELDS, 'lastConnectedAt'])
|
|
149
|
-
|
|
150
|
-
/** Fields a persisted record carries. */
|
|
151
|
-
const STORED_FIELDS = new Set(['id', ...CREATE_FIELDS, 'createdAt', 'lastConnectedAt'])
|
|
152
|
-
|
|
153
|
-
/** Allowed keys inside each auth shape. */
|
|
154
|
-
/** @type {Record<'key'|'password'|'agent', ReadonlySet<string>>} */
|
|
155
|
-
const AUTH_FIELDS = {
|
|
156
|
-
key: new Set(['kind', 'privateKeyPath']),
|
|
157
|
-
password: new Set(['kind', 'credentialRef']),
|
|
158
|
-
agent: new Set(['kind']),
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Fields that would put a secret into `servers.json`. They are rejected with a
|
|
163
|
-
* dedicated message rather than a generic "unknown field" one, because the
|
|
164
|
-
* constraint is security-relevant (doc §6.3).
|
|
165
|
-
*/
|
|
166
|
-
const SECRET_FIELDS = new Set(['password', 'passphrase', 'privateKey', 'privateKeyContent', 'secret', 'token'])
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Deep copy a record so callers cannot mutate this instance's state.
|
|
170
|
-
* @template T
|
|
171
|
-
* @param {T} value
|
|
172
|
-
* @returns {T}
|
|
173
|
-
*/
|
|
174
|
-
function clone(value) {
|
|
175
|
-
return structuredClone(value)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* @param {unknown} value
|
|
180
|
-
* @returns {value is Record<string, unknown>}
|
|
181
|
-
*/
|
|
182
|
-
function isPlainObject(value) {
|
|
183
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* @param {unknown} error
|
|
188
|
-
* @param {string} code
|
|
189
|
-
* @returns {boolean}
|
|
190
|
-
*/
|
|
191
|
-
function isErrno(error, code) {
|
|
192
|
-
return isPlainObject(error) && error.code === code
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* @param {unknown} value
|
|
197
|
-
* @returns {value is 'allow'|'confirm'|'block'}
|
|
198
|
-
*/
|
|
199
|
-
function isDecisionValue(value) {
|
|
200
|
-
return value === 'allow' || value === 'confirm' || value === 'block'
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* @param {string} field
|
|
205
|
-
* @param {RegistryErrorCode} errorCode
|
|
206
|
-
* @returns {RegistryError}
|
|
207
|
-
*/
|
|
208
|
-
function plaintextError(field, errorCode) {
|
|
209
|
-
return new RegistryError(
|
|
210
|
-
errorCode,
|
|
211
|
-
`Refusing to store "${field}" in servers.json: plaintext secrets are never stored. ` +
|
|
212
|
-
'Use auth.kind "key" with privateKeyPath, or auth.kind "password" with a credentialRef backed by ctx.credentials.',
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Reject unknown fields and plaintext secrets before any value is used.
|
|
218
|
-
* @param {Record<string, unknown>} source
|
|
219
|
-
* @param {ReadonlySet<string>} allowed
|
|
220
|
-
* @param {RegistryErrorCode} errorCode
|
|
221
|
-
* @param {string} label
|
|
222
|
-
* @returns {void}
|
|
223
|
-
*/
|
|
224
|
-
function assertKnownFields(source, allowed, errorCode, label) {
|
|
225
|
-
for (const key of Object.keys(source)) {
|
|
226
|
-
if (allowed.has(key)) continue
|
|
227
|
-
if (SECRET_FIELDS.has(key)) throw plaintextError(key, errorCode)
|
|
228
|
-
throw new RegistryError(
|
|
229
|
-
errorCode,
|
|
230
|
-
`Unknown field "${key}" in ${label}. Allowed fields: ${[...allowed].join(', ')}.`,
|
|
231
|
-
)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* @param {unknown} value
|
|
237
|
-
* @param {string} field
|
|
238
|
-
* @param {RegistryErrorCode} errorCode
|
|
239
|
-
* @returns {string}
|
|
240
|
-
*/
|
|
241
|
-
function requiredString(value, field, errorCode) {
|
|
242
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
243
|
-
throw new RegistryError(errorCode, `${field} must be a non-empty string.`)
|
|
244
|
-
}
|
|
245
|
-
return value.trim()
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* @param {unknown} value
|
|
250
|
-
* @param {RegistryErrorCode} errorCode
|
|
251
|
-
* @returns {string}
|
|
252
|
-
*/
|
|
253
|
-
function normalizeHost(value, errorCode) {
|
|
254
|
-
const host = requiredString(value, 'host', errorCode)
|
|
255
|
-
if (/\s/.test(host)) throw new RegistryError(errorCode, 'host must not contain whitespace.')
|
|
256
|
-
return host
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* @param {unknown} value
|
|
261
|
-
* @param {RegistryErrorCode} errorCode
|
|
262
|
-
* @returns {number}
|
|
263
|
-
*/
|
|
264
|
-
function normalizePort(value, errorCode) {
|
|
265
|
-
if (value === undefined) return 22
|
|
266
|
-
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
|
|
267
|
-
throw new RegistryError(errorCode, 'port must be an integer between 1 and 65535.')
|
|
268
|
-
}
|
|
269
|
-
return value
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* @param {unknown} value
|
|
274
|
-
* @param {RegistryErrorCode} errorCode
|
|
275
|
-
* @returns {'accept-new'|'verify'|'off'}
|
|
276
|
-
*/
|
|
277
|
-
function normalizeHostKeyMode(value, errorCode) {
|
|
278
|
-
if (value === undefined) return 'accept-new'
|
|
279
|
-
if (value === 'accept-new' || value === 'verify' || value === 'off') return value
|
|
280
|
-
throw new RegistryError(errorCode, `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* @param {unknown} value
|
|
285
|
-
* @param {RegistryErrorCode} errorCode
|
|
286
|
-
* @returns {string|null}
|
|
287
|
-
*/
|
|
288
|
-
function normalizeWorkDir(value, errorCode) {
|
|
289
|
-
if (value === undefined || value === null) return null
|
|
290
|
-
if (typeof value !== 'string') throw new RegistryError(errorCode, 'defaultWorkDir must be a string or null.')
|
|
291
|
-
const trimmed = value.trim()
|
|
292
|
-
return trimmed === '' ? null : trimmed
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* The enable switch (doc §3.1, enable-switch spec).
|
|
297
|
-
*
|
|
298
|
-
* Absent means enabled: files written before the switch existed carry no
|
|
299
|
-
* `enabled` key, and silently disabling every server on load would be a
|
|
300
|
-
* destructive surprise.
|
|
301
|
-
* @param {unknown} value
|
|
302
|
-
* @param {RegistryErrorCode} errorCode
|
|
303
|
-
* @returns {boolean}
|
|
304
|
-
*/
|
|
305
|
-
function normalizeEnabled(value, errorCode) {
|
|
306
|
-
if (value === undefined || value === null) return true
|
|
307
|
-
if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
|
|
308
|
-
return value
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* @param {unknown} value
|
|
313
|
-
* @param {string} field
|
|
314
|
-
* @param {RegistryErrorCode} errorCode
|
|
315
|
-
* @returns {string}
|
|
316
|
-
*/
|
|
317
|
-
function normalizeTimestamp(value, field, errorCode) {
|
|
318
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
319
|
-
throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
|
|
320
|
-
}
|
|
321
|
-
const parsed = new Date(value)
|
|
322
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
323
|
-
throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
|
|
324
|
-
}
|
|
325
|
-
return parsed.toISOString()
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* @param {unknown} value
|
|
330
|
-
* @param {string} field
|
|
331
|
-
* @param {RegistryErrorCode} errorCode
|
|
332
|
-
* @returns {string|null}
|
|
333
|
-
*/
|
|
334
|
-
function normalizeOptionalTimestamp(value, field, errorCode) {
|
|
335
|
-
if (value === undefined || value === null) return null
|
|
336
|
-
return normalizeTimestamp(value, field, errorCode)
|
|
337
|
-
}
|
|
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
|
-
|
|
417
|
-
/**
|
|
418
|
-
* @param {unknown} value
|
|
419
|
-
* @param {string} serverId
|
|
420
|
-
* @param {RegistryErrorCode} errorCode
|
|
421
|
-
* @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
|
|
422
|
-
* @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
|
|
423
|
-
*/
|
|
424
|
-
function normalizeAuth(value, serverId, errorCode, options = {}) {
|
|
425
|
-
if (value === undefined) return { kind: 'agent' }
|
|
426
|
-
if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
|
|
427
|
-
const kind = value.kind
|
|
428
|
-
if (kind === 'key') {
|
|
429
|
-
assertKnownFields(value, AUTH_FIELDS.key, errorCode, 'auth')
|
|
430
|
-
return { kind: 'key', privateKeyPath: requiredString(value.privateKeyPath, 'auth.privateKeyPath', errorCode) }
|
|
431
|
-
}
|
|
432
|
-
if (kind === 'password') {
|
|
433
|
-
assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
|
|
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.
|
|
442
|
-
const credentialRef =
|
|
443
|
-
value.credentialRef === undefined
|
|
444
|
-
? deriveCredentialRef(serverId)
|
|
445
|
-
: options.migrate === true
|
|
446
|
-
? migrateCredentialRef(value.credentialRef, serverId, errorCode)
|
|
447
|
-
: assertCredentialRef(value.credentialRef, errorCode)
|
|
448
|
-
return { kind: 'password', credentialRef }
|
|
449
|
-
}
|
|
450
|
-
if (kind === 'agent') {
|
|
451
|
-
assertKnownFields(value, AUTH_FIELDS.agent, errorCode, 'auth')
|
|
452
|
-
return { kind: 'agent' }
|
|
453
|
-
}
|
|
454
|
-
throw new RegistryError(errorCode, `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* @param {unknown} value
|
|
459
|
-
* @param {RegistryErrorCode} errorCode
|
|
460
|
-
* @returns {Record<string, 'allow'|'confirm'|'block'>}
|
|
461
|
-
*/
|
|
462
|
-
function normalizePolicy(value, errorCode) {
|
|
463
|
-
if (value === undefined) return {}
|
|
464
|
-
if (!isPlainObject(value)) {
|
|
465
|
-
throw new RegistryError(errorCode, 'policy must be an object of ruleId → allow|confirm|block.')
|
|
466
|
-
}
|
|
467
|
-
/** @type {Record<string, 'allow'|'confirm'|'block'>} */
|
|
468
|
-
const policy = {}
|
|
469
|
-
for (const [ruleId, decision] of Object.entries(value)) {
|
|
470
|
-
if (ruleId.trim() === '') throw new RegistryError(errorCode, 'policy keys must be non-empty rule ids.')
|
|
471
|
-
if (!isDecisionValue(decision)) {
|
|
472
|
-
throw new RegistryError(errorCode, `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
|
|
473
|
-
}
|
|
474
|
-
policy[ruleId] = decision
|
|
475
|
-
}
|
|
476
|
-
return policy
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* @param {unknown} value
|
|
481
|
-
* @param {RegistryErrorCode} errorCode
|
|
482
|
-
* @returns {string[]}
|
|
483
|
-
*/
|
|
484
|
-
function normalizeDisabledRules(value, errorCode) {
|
|
485
|
-
if (value === undefined) return []
|
|
486
|
-
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'disabledRules must be an array of rule ids.')
|
|
487
|
-
/** @type {string[]} */
|
|
488
|
-
const ids = []
|
|
489
|
-
for (const item of value) {
|
|
490
|
-
if (typeof item !== 'string' || item.trim() === '') {
|
|
491
|
-
throw new RegistryError(errorCode, 'disabledRules must contain non-empty rule ids.')
|
|
492
|
-
}
|
|
493
|
-
const id = item.trim()
|
|
494
|
-
if (!ids.includes(id)) ids.push(id)
|
|
495
|
-
}
|
|
496
|
-
return ids
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Validate the custom-rule list of one record (doc §4.4.2).
|
|
501
|
-
*
|
|
502
|
-
* Ids are unique per server and must not collide with a built-in id. The
|
|
503
|
-
* decision may only be `confirm` or `block` — a rule that permits what the
|
|
504
|
-
* engine already permits buys nothing.
|
|
505
|
-
* @param {unknown} value
|
|
506
|
-
* @param {RegistryErrorCode} errorCode
|
|
507
|
-
* @returns {import('./policy.js').CustomRule[]}
|
|
508
|
-
*/
|
|
509
|
-
function normalizeCustomRules(value, errorCode) {
|
|
510
|
-
if (value === undefined) return []
|
|
511
|
-
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
|
|
512
|
-
if (value.length > MAX_CUSTOM_RULES) {
|
|
513
|
-
throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
|
|
514
|
-
}
|
|
515
|
-
/** @type {import('./policy.js').CustomRule[]} */
|
|
516
|
-
const rules = []
|
|
517
|
-
/** @type {string[]} */
|
|
518
|
-
const seen = []
|
|
519
|
-
for (const item of value) {
|
|
520
|
-
/** @type {import('./policy.js').CustomRule} */
|
|
521
|
-
let rule
|
|
522
|
-
try {
|
|
523
|
-
rule = validateCustomRule(item, { takenIds: seen })
|
|
524
|
-
} catch (error) {
|
|
525
|
-
if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
|
|
526
|
-
throw error
|
|
527
|
-
}
|
|
528
|
-
seen.push(rule.id)
|
|
529
|
-
rules.push(rule)
|
|
530
|
-
}
|
|
531
|
-
return rules
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Validate and canonicalise one complete record. Create, update and load all
|
|
536
|
-
* go through this function, so a stored record and a freshly created one have
|
|
537
|
-
* exactly the same shape.
|
|
538
|
-
* @param {Record<string, unknown>} source
|
|
539
|
-
* @param {{
|
|
540
|
-
* allowed: ReadonlySet<string>,
|
|
541
|
-
* id: string,
|
|
542
|
-
* errorCode: RegistryErrorCode,
|
|
543
|
-
* label: string,
|
|
544
|
-
* createdAt?: string,
|
|
545
|
-
* migrate?: boolean,
|
|
546
|
-
* }} options
|
|
547
|
-
* @returns {ServerRecord}
|
|
548
|
-
*/
|
|
549
|
-
function normalizeRecord(source, options) {
|
|
550
|
-
const { allowed, id, errorCode, label } = options
|
|
551
|
-
if (!isPlainObject(source)) throw new RegistryError(errorCode, `${label} must be an object.`)
|
|
552
|
-
assertKnownFields(source, allowed, errorCode, label)
|
|
553
|
-
const createdAt =
|
|
554
|
-
options.createdAt !== undefined
|
|
555
|
-
? normalizeTimestamp(options.createdAt, 'createdAt', errorCode)
|
|
556
|
-
: normalizeTimestamp(source.createdAt, 'createdAt', errorCode)
|
|
557
|
-
return {
|
|
558
|
-
id,
|
|
559
|
-
name: requiredString(source.name, 'name', errorCode),
|
|
560
|
-
host: normalizeHost(source.host, errorCode),
|
|
561
|
-
port: normalizePort(source.port, errorCode),
|
|
562
|
-
username: requiredString(source.username, 'username', errorCode),
|
|
563
|
-
auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
|
|
564
|
-
hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
|
|
565
|
-
defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
|
|
566
|
-
enabled: normalizeEnabled(source.enabled, errorCode),
|
|
567
|
-
policy: normalizePolicy(source.policy, errorCode),
|
|
568
|
-
disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
|
|
569
|
-
customRules: normalizeCustomRules(source.customRules, errorCode),
|
|
570
|
-
createdAt,
|
|
571
|
-
lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Case- and whitespace-insensitive comparison key for the unique display name.
|
|
577
|
-
* @param {string} name
|
|
578
|
-
* @returns {string}
|
|
579
|
-
*/
|
|
580
|
-
function normalizeNameKey(name) {
|
|
581
|
-
return name.trim().toLowerCase()
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* @param {ServerRecord[]} servers
|
|
586
|
-
* @returns {string}
|
|
587
|
-
*/
|
|
588
|
-
function describeServers(servers) {
|
|
589
|
-
if (servers.length === 0) return 'none configured'
|
|
590
|
-
return servers.map((server) => `${server.id} (${server.name})`).join(', ')
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* @param {string} id
|
|
595
|
-
* @param {ServerRecord[]} servers
|
|
596
|
-
* @returns {RegistryError}
|
|
597
|
-
*/
|
|
598
|
-
function unknownServer(id, servers) {
|
|
599
|
-
return new RegistryError('UNKNOWN_SERVER', `Unknown server "${id}". Available servers: ${describeServers(servers)}.`, {
|
|
600
|
-
available: servers.map((server) => server.id),
|
|
601
|
-
})
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* @param {ServerRecord[]} servers
|
|
606
|
-
* @param {string} name
|
|
607
|
-
* @param {string|null} exceptId - the record being renamed, or null on create.
|
|
608
|
-
* @returns {void}
|
|
609
|
-
*/
|
|
610
|
-
function assertNameAvailable(servers, name, exceptId) {
|
|
611
|
-
const key = normalizeNameKey(name)
|
|
612
|
-
for (const server of servers) {
|
|
613
|
-
if (server.id === exceptId) continue
|
|
614
|
-
if (normalizeNameKey(server.name) === key) {
|
|
615
|
-
throw new RegistryError(
|
|
616
|
-
'DUPLICATE_NAME',
|
|
617
|
-
`A server named ${JSON.stringify(name)} already exists (id ${server.id}). Server names are unique.`,
|
|
618
|
-
)
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Next sequential id, e.g. `srv-01`. The width grows to fit the number.
|
|
625
|
-
* @param {ServerRecord[]} servers
|
|
626
|
-
* @returns {string}
|
|
627
|
-
*/
|
|
628
|
-
function nextServerId(servers) {
|
|
629
|
-
let highest = 0
|
|
630
|
-
for (const server of servers) {
|
|
631
|
-
const match = /^srv-(\d+)$/.exec(server.id)
|
|
632
|
-
if (match === null) continue
|
|
633
|
-
const digits = match[1]
|
|
634
|
-
if (digits === undefined) continue
|
|
635
|
-
const value = Number(digits)
|
|
636
|
-
if (Number.isSafeInteger(value) && value > highest) highest = value
|
|
637
|
-
}
|
|
638
|
-
const next = highest + 1
|
|
639
|
-
const width = next >= 100 ? String(next).length : 2
|
|
640
|
-
return `srv-${String(next).padStart(width, '0')}`
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* Drop undefined patch entries and reject immutable or unknown fields before
|
|
645
|
-
* they are merged into a record.
|
|
646
|
-
* @param {Record<string, unknown>} patch
|
|
647
|
-
* @returns {Record<string, unknown>}
|
|
648
|
-
*/
|
|
649
|
-
function normalizePatch(patch) {
|
|
650
|
-
if (!isPlainObject(patch)) throw new RegistryError('INVALID_SERVER', 'update patch must be an object.')
|
|
651
|
-
/** @type {Record<string, unknown>} */
|
|
652
|
-
const changes = {}
|
|
653
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
654
|
-
if (value === undefined) continue
|
|
655
|
-
if (key === 'id' || key === 'createdAt') {
|
|
656
|
-
throw new RegistryError('INVALID_SERVER', `${key} is immutable; create a new server instead.`)
|
|
657
|
-
}
|
|
658
|
-
if (!UPDATE_FIELDS.has(key)) {
|
|
659
|
-
if (SECRET_FIELDS.has(key)) throw plaintextError(key, 'INVALID_SERVER')
|
|
660
|
-
throw new RegistryError('INVALID_SERVER', `Unknown field "${key}" in update.`)
|
|
661
|
-
}
|
|
662
|
-
changes[key] = value
|
|
663
|
-
}
|
|
664
|
-
return changes
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* CRUD facade over one `servers.json` file.
|
|
669
|
-
*
|
|
670
|
-
* All methods are asynchronous; reads go straight to disk, mutations are
|
|
671
|
-
* queued per file and committed atomically. Use {@link Registry.resolve} for
|
|
672
|
-
* the forgiving server addressing described in doc §5.2.
|
|
673
|
-
*/
|
|
674
|
-
export class Registry {
|
|
675
|
-
/** @type {string} */
|
|
676
|
-
#file
|
|
677
|
-
/**
|
|
678
|
-
* @param {{file?: string}} [options] - `file` overrides the default path and is
|
|
679
|
-
* what the tests use; production code relies on `$DSH_HOME`.
|
|
680
|
-
*/
|
|
681
|
-
constructor(options = {}) {
|
|
682
|
-
this.#file = options.file ?? defaultServersFile()
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** Absolute path of the registry file this instance reads and writes. */
|
|
686
|
-
get file() {
|
|
687
|
-
return this.#file
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Every server, in file order.
|
|
692
|
-
* @returns {Promise<ServerRecord[]>}
|
|
693
|
-
*/
|
|
694
|
-
async list() {
|
|
695
|
-
const document = await this.#load()
|
|
696
|
-
return document.servers.map((server) => clone(server))
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Exact lookup by stable id.
|
|
701
|
-
* @param {string} id
|
|
702
|
-
* @returns {Promise<ServerRecord|null>} null when no server has that id.
|
|
703
|
-
*/
|
|
704
|
-
async get(id) {
|
|
705
|
-
const document = await this.#load()
|
|
706
|
-
const record = document.servers.find((server) => server.id === id)
|
|
707
|
-
return record === undefined ? null : clone(record)
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Forgiving lookup for a tool's `server` argument (doc §5.2):
|
|
712
|
-
* 1. exact `id`;
|
|
713
|
-
* 2. exact `name` (case-insensitive);
|
|
714
|
-
* 3. unique prefix of an id or a name (case-insensitive);
|
|
715
|
-
* 4. zero matches → `UNKNOWN_SERVER`, message listing every server;
|
|
716
|
-
* 5. several matches → `AMBIGUOUS_SERVER`, message listing the candidates.
|
|
717
|
-
*
|
|
718
|
-
* Exact id/name matches win over prefix matches, so a name that prefixes
|
|
719
|
-
* another name remains addressable.
|
|
720
|
-
* @param {string} query
|
|
721
|
-
* @returns {Promise<ServerRecord>}
|
|
722
|
-
* @throws {RegistryError} `INVALID_SERVER`, `UNKNOWN_SERVER` or `AMBIGUOUS_SERVER`.
|
|
723
|
-
*/
|
|
724
|
-
async resolve(query) {
|
|
725
|
-
if (typeof query !== 'string' || query.trim() === '') {
|
|
726
|
-
throw new RegistryError('INVALID_SERVER', 'server must be a non-empty id, name or unique prefix.')
|
|
727
|
-
}
|
|
728
|
-
const wanted = query.trim()
|
|
729
|
-
const document = await this.#load()
|
|
730
|
-
const servers = document.servers
|
|
731
|
-
|
|
732
|
-
const exactId = servers.find((server) => server.id === wanted)
|
|
733
|
-
if (exactId !== undefined) return clone(exactId)
|
|
734
|
-
|
|
735
|
-
const wantedName = wanted.toLowerCase()
|
|
736
|
-
const exactName = servers.find((server) => server.name.toLowerCase() === wantedName)
|
|
737
|
-
if (exactName !== undefined) return clone(exactName)
|
|
738
|
-
|
|
739
|
-
const prefix = wanted.toLowerCase()
|
|
740
|
-
const candidates = servers.filter(
|
|
741
|
-
(server) => server.id.toLowerCase().startsWith(prefix) || server.name.toLowerCase().startsWith(prefix),
|
|
742
|
-
)
|
|
743
|
-
const only = candidates.length === 1 ? candidates[0] : undefined
|
|
744
|
-
if (only !== undefined) return clone(only)
|
|
745
|
-
if (candidates.length === 0) throw unknownServer(wanted, servers)
|
|
746
|
-
throw new RegistryError(
|
|
747
|
-
'AMBIGUOUS_SERVER',
|
|
748
|
-
`Server "${wanted}" is ambiguous: matches ${candidates
|
|
749
|
-
.map((server) => `${server.id} (${server.name})`)
|
|
750
|
-
.join(', ')}. Use the full id or name.`,
|
|
751
|
-
{ candidates: candidates.map((server) => server.id) },
|
|
752
|
-
)
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Create a server. The id is assigned by the registry; `createdAt` is now.
|
|
757
|
-
* @param {Record<string, unknown>} input
|
|
758
|
-
* @returns {Promise<ServerRecord>} the stored record.
|
|
759
|
-
* @throws {RegistryError} `INVALID_SERVER` or `DUPLICATE_NAME`.
|
|
760
|
-
*/
|
|
761
|
-
async create(input) {
|
|
762
|
-
return this.#mutate((document) => {
|
|
763
|
-
const id = nextServerId(document.servers)
|
|
764
|
-
const record = normalizeRecord(input, {
|
|
765
|
-
allowed: CREATE_FIELDS,
|
|
766
|
-
id,
|
|
767
|
-
errorCode: 'INVALID_SERVER',
|
|
768
|
-
label: 'server',
|
|
769
|
-
createdAt: new Date().toISOString(),
|
|
770
|
-
})
|
|
771
|
-
assertNameAvailable(document.servers, record.name, null)
|
|
772
|
-
document.servers.push(record)
|
|
773
|
-
return clone(record)
|
|
774
|
-
})
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Change some fields of one server. `id` and `createdAt` cannot be changed;
|
|
779
|
-
* omitted fields keep their current value. The merged record is validated as
|
|
780
|
-
* a whole before it replaces the stored one.
|
|
781
|
-
* @param {string} id
|
|
782
|
-
* @param {Record<string, unknown>} patch
|
|
783
|
-
* @returns {Promise<ServerRecord>} the stored record.
|
|
784
|
-
* @throws {RegistryError} `UNKNOWN_SERVER`, `INVALID_SERVER` or `DUPLICATE_NAME`.
|
|
785
|
-
*/
|
|
786
|
-
async update(id, patch) {
|
|
787
|
-
return this.#mutate((document) => {
|
|
788
|
-
const index = document.servers.findIndex((server) => server.id === id)
|
|
789
|
-
const existing = index === -1 ? undefined : document.servers[index]
|
|
790
|
-
if (existing === undefined) throw unknownServer(id, document.servers)
|
|
791
|
-
const changes = normalizePatch(patch)
|
|
792
|
-
/** @type {Record<string, unknown>} */
|
|
793
|
-
const merged = { ...existing }
|
|
794
|
-
for (const [key, value] of Object.entries(changes)) merged[key] = value
|
|
795
|
-
const record = normalizeRecord(merged, {
|
|
796
|
-
allowed: STORED_FIELDS,
|
|
797
|
-
id: existing.id,
|
|
798
|
-
errorCode: 'INVALID_SERVER',
|
|
799
|
-
label: 'server',
|
|
800
|
-
createdAt: existing.createdAt,
|
|
801
|
-
})
|
|
802
|
-
assertNameAvailable(document.servers, record.name, existing.id)
|
|
803
|
-
document.servers[index] = record
|
|
804
|
-
return clone(record)
|
|
805
|
-
})
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Delete one server, returning the record that was removed.
|
|
810
|
-
* @param {string} id
|
|
811
|
-
* @returns {Promise<ServerRecord>}
|
|
812
|
-
* @throws {RegistryError} `UNKNOWN_SERVER`.
|
|
813
|
-
*/
|
|
814
|
-
async remove(id) {
|
|
815
|
-
return this.#mutate((document) => {
|
|
816
|
-
const index = document.servers.findIndex((server) => server.id === id)
|
|
817
|
-
const existing = index === -1 ? undefined : document.servers[index]
|
|
818
|
-
if (existing === undefined) throw unknownServer(id, document.servers)
|
|
819
|
-
document.servers.splice(index, 1)
|
|
820
|
-
return clone(existing)
|
|
821
|
-
})
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* Read and validate the whole file. A missing or empty file is an empty
|
|
826
|
-
* registry; invalid JSON, an unknown version, duplicate ids/names or a
|
|
827
|
-
* malformed record is reported as `INVALID_REGISTRY` instead of being
|
|
828
|
-
* silently rewritten.
|
|
829
|
-
* @returns {Promise<RegistryDocument>}
|
|
830
|
-
*/
|
|
831
|
-
async #load() {
|
|
832
|
-
/** @type {string} */
|
|
833
|
-
let text
|
|
834
|
-
try {
|
|
835
|
-
text = await readFile(this.#file, 'utf8')
|
|
836
|
-
} catch (error) {
|
|
837
|
-
if (isErrno(error, 'ENOENT')) return { version: REGISTRY_VERSION, servers: [] }
|
|
838
|
-
throw error
|
|
839
|
-
}
|
|
840
|
-
if (text.trim() === '') return { version: REGISTRY_VERSION, servers: [] }
|
|
841
|
-
|
|
842
|
-
/** @type {unknown} */
|
|
843
|
-
let parsed
|
|
844
|
-
try {
|
|
845
|
-
parsed = JSON.parse(text)
|
|
846
|
-
} catch {
|
|
847
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file} is not valid JSON; fix or remove it.`)
|
|
848
|
-
}
|
|
849
|
-
if (!isPlainObject(parsed)) {
|
|
850
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a JSON object.`)
|
|
851
|
-
}
|
|
852
|
-
if (parsed.version !== REGISTRY_VERSION) {
|
|
853
|
-
throw new RegistryError(
|
|
854
|
-
'INVALID_REGISTRY',
|
|
855
|
-
`${this.#file} has unsupported version ${String(parsed.version)}; expected ${REGISTRY_VERSION}.`,
|
|
856
|
-
)
|
|
857
|
-
}
|
|
858
|
-
if (!Array.isArray(parsed.servers)) {
|
|
859
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a "servers" array.`)
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/** @type {ServerRecord[]} */
|
|
863
|
-
const servers = []
|
|
864
|
-
const seenIds = new Set()
|
|
865
|
-
const seenNames = new Set()
|
|
866
|
-
for (let index = 0; index < parsed.servers.length; index += 1) {
|
|
867
|
-
const raw = parsed.servers[index]
|
|
868
|
-
if (!isPlainObject(raw)) {
|
|
869
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: servers[${index}] is not an object.`)
|
|
870
|
-
}
|
|
871
|
-
const recordId = requiredString(raw.id, `servers[${index}].id`, 'INVALID_REGISTRY')
|
|
872
|
-
const record = normalizeRecord(raw, {
|
|
873
|
-
allowed: STORED_FIELDS,
|
|
874
|
-
id: recordId,
|
|
875
|
-
errorCode: 'INVALID_REGISTRY',
|
|
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,
|
|
880
|
-
})
|
|
881
|
-
if (seenIds.has(record.id)) {
|
|
882
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
|
|
883
|
-
}
|
|
884
|
-
seenIds.add(record.id)
|
|
885
|
-
const nameKey = normalizeNameKey(record.name)
|
|
886
|
-
if (seenNames.has(nameKey)) {
|
|
887
|
-
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server name ${record.name}.`)
|
|
888
|
-
}
|
|
889
|
-
seenNames.add(nameKey)
|
|
890
|
-
servers.push(record)
|
|
891
|
-
}
|
|
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
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* Commit the document by writing a sibling temp file and renaming it over
|
|
922
|
-
* the registry. Readers only ever observe the old or the new complete file.
|
|
923
|
-
* @param {RegistryDocument} document
|
|
924
|
-
* @returns {Promise<void>}
|
|
925
|
-
*/
|
|
926
|
-
async #save(document) {
|
|
927
|
-
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
928
|
-
const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
929
|
-
try {
|
|
930
|
-
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
931
|
-
await rename(temporary, this.#file)
|
|
932
|
-
} catch (error) {
|
|
933
|
-
await unlink(temporary).catch(() => {})
|
|
934
|
-
throw error
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Run one read-modify-write operation after every earlier mutation finished.
|
|
940
|
-
*
|
|
941
|
-
* The queue is keyed by the absolute file path, not by this instance: the
|
|
942
|
-
* tools surface, the HTTP routes and the tests each build their own Registry,
|
|
943
|
-
* and two instances on the same file must not lose each other's writes.
|
|
944
|
-
* @template T
|
|
945
|
-
* @param {(document: RegistryDocument) => T} mutate
|
|
946
|
-
* @returns {Promise<T>}
|
|
947
|
-
*/
|
|
948
|
-
async #mutate(mutate) {
|
|
949
|
-
const key = resolve(this.#file)
|
|
950
|
-
const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
|
|
951
|
-
const operation = previous.then(async () => {
|
|
952
|
-
const document = await this.#load()
|
|
953
|
-
const result = mutate(document)
|
|
954
|
-
await this.#save(document)
|
|
955
|
-
return result
|
|
956
|
-
})
|
|
957
|
-
const settled = operation.then(
|
|
958
|
-
() => undefined,
|
|
959
|
-
() => undefined,
|
|
960
|
-
)
|
|
961
|
-
MUTATION_QUEUES.set(key, settled)
|
|
962
|
-
// Keep the map from growing once a file goes quiet.
|
|
963
|
-
void settled.then(() => {
|
|
964
|
-
if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
|
|
965
|
-
})
|
|
966
|
-
return operation
|
|
967
|
-
}
|
|
968
|
-
}
|
|
1
|
+
// dsh-server-connector — server registry (design doc §3.1/§3.5).
|
|
2
|
+
//
|
|
3
|
+
// The registry owns exactly one thing: CRUD over server records in
|
|
4
|
+
// `$DSH_HOME/server-connector/servers.json`. It knows nothing about SSH,
|
|
5
|
+
// policy evaluation or DSH; `lib/tools.js` (a later phase) composes it.
|
|
6
|
+
//
|
|
7
|
+
// Guarantees:
|
|
8
|
+
// - every mutation validates a complete record before anything is written;
|
|
9
|
+
// - the write is an atomic replacement (temp file in the same directory +
|
|
10
|
+
// rename), so a crash never leaves a half-written JSON document;
|
|
11
|
+
// - mutations are serialised per instance, so two concurrent create() calls
|
|
12
|
+
// cannot read the same document and silently lose one server;
|
|
13
|
+
// - secrets are never persisted: password auth keeps only a `credentialRef`.
|
|
14
|
+
//
|
|
15
|
+
// File layout (doc §3.5):
|
|
16
|
+
// $DSH_HOME/server-connector/servers.json
|
|
17
|
+
// { "version": 1, "servers": [ ... ] }
|
|
18
|
+
|
|
19
|
+
import { randomBytes } from 'node:crypto'
|
|
20
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { dirname, join, resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
import { MAX_CUSTOM_RULES, RuleError, validateCustomRule } from './policy.js'
|
|
25
|
+
|
|
26
|
+
/** Schema version written to, and required from, `servers.json`. */
|
|
27
|
+
export const REGISTRY_VERSION = 1
|
|
28
|
+
|
|
29
|
+
/** Directory below `$DSH_HOME` that holds every file of this plugin (doc §3.5). */
|
|
30
|
+
export const CONNECTOR_DIR_NAME = 'server-connector'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One tail promise per registry file, so every Registry instance in this
|
|
34
|
+
* process serialises its mutations against the same document.
|
|
35
|
+
* @type {Map<string, Promise<void>>}
|
|
36
|
+
*/
|
|
37
|
+
const MUTATION_QUEUES = new Map()
|
|
38
|
+
|
|
39
|
+
/** File name of the server registry. */
|
|
40
|
+
export const SERVERS_FILE_NAME = 'servers.json'
|
|
41
|
+
|
|
42
|
+
/** The three auth shapes of doc §3.1. */
|
|
43
|
+
export const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
|
|
44
|
+
|
|
45
|
+
/** The three host-key verification modes of doc §6.4. */
|
|
46
|
+
export const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
|
|
47
|
+
|
|
48
|
+
/** The three per-server policy levels of doc §4.2. */
|
|
49
|
+
export const DECISION_VALUES = Object.freeze(['allow', 'confirm', 'block'])
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One server record exactly as persisted (doc §3.1).
|
|
53
|
+
* @typedef {object} ServerRecord
|
|
54
|
+
* @property {string} id - stable addressing key; names may change, ids must not.
|
|
55
|
+
* @property {string} name - unique display name.
|
|
56
|
+
* @property {string} host
|
|
57
|
+
* @property {number} port
|
|
58
|
+
* @property {string} username
|
|
59
|
+
* @property {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}} auth
|
|
60
|
+
* @property {'accept-new'|'verify'|'off'} hostKeyMode
|
|
61
|
+
* @property {string|null} defaultWorkDir
|
|
62
|
+
* @property {boolean} enabled
|
|
63
|
+
* @property {Record<string, 'allow'|'confirm'|'block'>} policy
|
|
64
|
+
* @property {string[]} disabledRules
|
|
65
|
+
* @property {import('./policy.js').CustomRule[]} customRules
|
|
66
|
+
* @property {string} createdAt
|
|
67
|
+
* @property {string|null} lastConnectedAt
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The parsed file: a schema version plus the records.
|
|
72
|
+
* @typedef {object} RegistryDocument
|
|
73
|
+
* @property {number} version
|
|
74
|
+
* @property {ServerRecord[]} servers
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Machine-readable registry failure. `UNKNOWN_SERVER` and `AMBIGUOUS_SERVER`
|
|
79
|
+
* are the tool-facing codes of doc §5.5; the rest describe input or storage
|
|
80
|
+
* problems. The message is written for both the model and the panel: unknown
|
|
81
|
+
* servers list every configured server, ambiguous ones list the candidates, so
|
|
82
|
+
* a caller can self-correct in one turn (doc §5.2).
|
|
83
|
+
* @typedef {'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'DUPLICATE_NAME'|'INVALID_SERVER'|'INVALID_REGISTRY'} RegistryErrorCode
|
|
84
|
+
*/
|
|
85
|
+
export class RegistryError extends Error {
|
|
86
|
+
/**
|
|
87
|
+
* @param {RegistryErrorCode} code
|
|
88
|
+
* @param {string} message
|
|
89
|
+
* @param {{available?: string[], candidates?: string[]}} [details]
|
|
90
|
+
*/
|
|
91
|
+
constructor(code, message, details = {}) {
|
|
92
|
+
super(message)
|
|
93
|
+
this.name = 'RegistryError'
|
|
94
|
+
/** @type {RegistryErrorCode} */
|
|
95
|
+
this.code = code
|
|
96
|
+
/** @type {string[]|undefined} */
|
|
97
|
+
this.available = details.available
|
|
98
|
+
/** @type {string[]|undefined} */
|
|
99
|
+
this.candidates = details.candidates
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The harness home: `$DSH_HOME` when set, otherwise `~/.dsh` (the same default
|
|
105
|
+
* the harness itself uses).
|
|
106
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function dshHome(env = process.env) {
|
|
110
|
+
const configured = env.DSH_HOME
|
|
111
|
+
return configured !== undefined && configured.trim() !== '' ? configured.trim() : join(homedir(), '.dsh')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The plugin's data directory below the harness home.
|
|
116
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function connectorDirectory(env = process.env) {
|
|
120
|
+
return join(dshHome(env), CONNECTOR_DIR_NAME)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Default location of `servers.json` (doc §3.5).
|
|
125
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function defaultServersFile(env = process.env) {
|
|
129
|
+
return join(connectorDirectory(env), SERVERS_FILE_NAME)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fields a create() call accepts. */
|
|
133
|
+
const CREATE_FIELDS = new Set([
|
|
134
|
+
'name',
|
|
135
|
+
'host',
|
|
136
|
+
'port',
|
|
137
|
+
'username',
|
|
138
|
+
'auth',
|
|
139
|
+
'hostKeyMode',
|
|
140
|
+
'enabled',
|
|
141
|
+
'defaultWorkDir',
|
|
142
|
+
'policy',
|
|
143
|
+
'disabledRules',
|
|
144
|
+
'customRules',
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
/** Fields an update() call accepts; `id` and `createdAt` are immutable. */
|
|
148
|
+
const UPDATE_FIELDS = new Set([...CREATE_FIELDS, 'lastConnectedAt'])
|
|
149
|
+
|
|
150
|
+
/** Fields a persisted record carries. */
|
|
151
|
+
const STORED_FIELDS = new Set(['id', ...CREATE_FIELDS, 'createdAt', 'lastConnectedAt'])
|
|
152
|
+
|
|
153
|
+
/** Allowed keys inside each auth shape. */
|
|
154
|
+
/** @type {Record<'key'|'password'|'agent', ReadonlySet<string>>} */
|
|
155
|
+
const AUTH_FIELDS = {
|
|
156
|
+
key: new Set(['kind', 'privateKeyPath']),
|
|
157
|
+
password: new Set(['kind', 'credentialRef']),
|
|
158
|
+
agent: new Set(['kind']),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fields that would put a secret into `servers.json`. They are rejected with a
|
|
163
|
+
* dedicated message rather than a generic "unknown field" one, because the
|
|
164
|
+
* constraint is security-relevant (doc §6.3).
|
|
165
|
+
*/
|
|
166
|
+
const SECRET_FIELDS = new Set(['password', 'passphrase', 'privateKey', 'privateKeyContent', 'secret', 'token'])
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Deep copy a record so callers cannot mutate this instance's state.
|
|
170
|
+
* @template T
|
|
171
|
+
* @param {T} value
|
|
172
|
+
* @returns {T}
|
|
173
|
+
*/
|
|
174
|
+
function clone(value) {
|
|
175
|
+
return structuredClone(value)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {unknown} value
|
|
180
|
+
* @returns {value is Record<string, unknown>}
|
|
181
|
+
*/
|
|
182
|
+
function isPlainObject(value) {
|
|
183
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} error
|
|
188
|
+
* @param {string} code
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
function isErrno(error, code) {
|
|
192
|
+
return isPlainObject(error) && error.code === code
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {unknown} value
|
|
197
|
+
* @returns {value is 'allow'|'confirm'|'block'}
|
|
198
|
+
*/
|
|
199
|
+
function isDecisionValue(value) {
|
|
200
|
+
return value === 'allow' || value === 'confirm' || value === 'block'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} field
|
|
205
|
+
* @param {RegistryErrorCode} errorCode
|
|
206
|
+
* @returns {RegistryError}
|
|
207
|
+
*/
|
|
208
|
+
function plaintextError(field, errorCode) {
|
|
209
|
+
return new RegistryError(
|
|
210
|
+
errorCode,
|
|
211
|
+
`Refusing to store "${field}" in servers.json: plaintext secrets are never stored. ` +
|
|
212
|
+
'Use auth.kind "key" with privateKeyPath, or auth.kind "password" with a credentialRef backed by ctx.credentials.',
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reject unknown fields and plaintext secrets before any value is used.
|
|
218
|
+
* @param {Record<string, unknown>} source
|
|
219
|
+
* @param {ReadonlySet<string>} allowed
|
|
220
|
+
* @param {RegistryErrorCode} errorCode
|
|
221
|
+
* @param {string} label
|
|
222
|
+
* @returns {void}
|
|
223
|
+
*/
|
|
224
|
+
function assertKnownFields(source, allowed, errorCode, label) {
|
|
225
|
+
for (const key of Object.keys(source)) {
|
|
226
|
+
if (allowed.has(key)) continue
|
|
227
|
+
if (SECRET_FIELDS.has(key)) throw plaintextError(key, errorCode)
|
|
228
|
+
throw new RegistryError(
|
|
229
|
+
errorCode,
|
|
230
|
+
`Unknown field "${key}" in ${label}. Allowed fields: ${[...allowed].join(', ')}.`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {unknown} value
|
|
237
|
+
* @param {string} field
|
|
238
|
+
* @param {RegistryErrorCode} errorCode
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
function requiredString(value, field, errorCode) {
|
|
242
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
243
|
+
throw new RegistryError(errorCode, `${field} must be a non-empty string.`)
|
|
244
|
+
}
|
|
245
|
+
return value.trim()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {unknown} value
|
|
250
|
+
* @param {RegistryErrorCode} errorCode
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
function normalizeHost(value, errorCode) {
|
|
254
|
+
const host = requiredString(value, 'host', errorCode)
|
|
255
|
+
if (/\s/.test(host)) throw new RegistryError(errorCode, 'host must not contain whitespace.')
|
|
256
|
+
return host
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {unknown} value
|
|
261
|
+
* @param {RegistryErrorCode} errorCode
|
|
262
|
+
* @returns {number}
|
|
263
|
+
*/
|
|
264
|
+
function normalizePort(value, errorCode) {
|
|
265
|
+
if (value === undefined) return 22
|
|
266
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
|
|
267
|
+
throw new RegistryError(errorCode, 'port must be an integer between 1 and 65535.')
|
|
268
|
+
}
|
|
269
|
+
return value
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {unknown} value
|
|
274
|
+
* @param {RegistryErrorCode} errorCode
|
|
275
|
+
* @returns {'accept-new'|'verify'|'off'}
|
|
276
|
+
*/
|
|
277
|
+
function normalizeHostKeyMode(value, errorCode) {
|
|
278
|
+
if (value === undefined) return 'accept-new'
|
|
279
|
+
if (value === 'accept-new' || value === 'verify' || value === 'off') return value
|
|
280
|
+
throw new RegistryError(errorCode, `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @param {unknown} value
|
|
285
|
+
* @param {RegistryErrorCode} errorCode
|
|
286
|
+
* @returns {string|null}
|
|
287
|
+
*/
|
|
288
|
+
function normalizeWorkDir(value, errorCode) {
|
|
289
|
+
if (value === undefined || value === null) return null
|
|
290
|
+
if (typeof value !== 'string') throw new RegistryError(errorCode, 'defaultWorkDir must be a string or null.')
|
|
291
|
+
const trimmed = value.trim()
|
|
292
|
+
return trimmed === '' ? null : trimmed
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The enable switch (doc §3.1, enable-switch spec).
|
|
297
|
+
*
|
|
298
|
+
* Absent means enabled: files written before the switch existed carry no
|
|
299
|
+
* `enabled` key, and silently disabling every server on load would be a
|
|
300
|
+
* destructive surprise.
|
|
301
|
+
* @param {unknown} value
|
|
302
|
+
* @param {RegistryErrorCode} errorCode
|
|
303
|
+
* @returns {boolean}
|
|
304
|
+
*/
|
|
305
|
+
function normalizeEnabled(value, errorCode) {
|
|
306
|
+
if (value === undefined || value === null) return true
|
|
307
|
+
if (typeof value !== 'boolean') throw new RegistryError(errorCode, 'enabled must be a boolean.')
|
|
308
|
+
return value
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {unknown} value
|
|
313
|
+
* @param {string} field
|
|
314
|
+
* @param {RegistryErrorCode} errorCode
|
|
315
|
+
* @returns {string}
|
|
316
|
+
*/
|
|
317
|
+
function normalizeTimestamp(value, field, errorCode) {
|
|
318
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
319
|
+
throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
|
|
320
|
+
}
|
|
321
|
+
const parsed = new Date(value)
|
|
322
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
323
|
+
throw new RegistryError(errorCode, `${field} must be an ISO-8601 timestamp.`)
|
|
324
|
+
}
|
|
325
|
+
return parsed.toISOString()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {unknown} value
|
|
330
|
+
* @param {string} field
|
|
331
|
+
* @param {RegistryErrorCode} errorCode
|
|
332
|
+
* @returns {string|null}
|
|
333
|
+
*/
|
|
334
|
+
function normalizeOptionalTimestamp(value, field, errorCode) {
|
|
335
|
+
if (value === undefined || value === null) return null
|
|
336
|
+
return normalizeTimestamp(value, field, errorCode)
|
|
337
|
+
}
|
|
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
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {unknown} value
|
|
419
|
+
* @param {string} serverId
|
|
420
|
+
* @param {RegistryErrorCode} errorCode
|
|
421
|
+
* @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
|
|
422
|
+
* @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
|
|
423
|
+
*/
|
|
424
|
+
function normalizeAuth(value, serverId, errorCode, options = {}) {
|
|
425
|
+
if (value === undefined) return { kind: 'agent' }
|
|
426
|
+
if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
|
|
427
|
+
const kind = value.kind
|
|
428
|
+
if (kind === 'key') {
|
|
429
|
+
assertKnownFields(value, AUTH_FIELDS.key, errorCode, 'auth')
|
|
430
|
+
return { kind: 'key', privateKeyPath: requiredString(value.privateKeyPath, 'auth.privateKeyPath', errorCode) }
|
|
431
|
+
}
|
|
432
|
+
if (kind === 'password') {
|
|
433
|
+
assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
|
|
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.
|
|
442
|
+
const credentialRef =
|
|
443
|
+
value.credentialRef === undefined
|
|
444
|
+
? deriveCredentialRef(serverId)
|
|
445
|
+
: options.migrate === true
|
|
446
|
+
? migrateCredentialRef(value.credentialRef, serverId, errorCode)
|
|
447
|
+
: assertCredentialRef(value.credentialRef, errorCode)
|
|
448
|
+
return { kind: 'password', credentialRef }
|
|
449
|
+
}
|
|
450
|
+
if (kind === 'agent') {
|
|
451
|
+
assertKnownFields(value, AUTH_FIELDS.agent, errorCode, 'auth')
|
|
452
|
+
return { kind: 'agent' }
|
|
453
|
+
}
|
|
454
|
+
throw new RegistryError(errorCode, `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @param {unknown} value
|
|
459
|
+
* @param {RegistryErrorCode} errorCode
|
|
460
|
+
* @returns {Record<string, 'allow'|'confirm'|'block'>}
|
|
461
|
+
*/
|
|
462
|
+
function normalizePolicy(value, errorCode) {
|
|
463
|
+
if (value === undefined) return {}
|
|
464
|
+
if (!isPlainObject(value)) {
|
|
465
|
+
throw new RegistryError(errorCode, 'policy must be an object of ruleId → allow|confirm|block.')
|
|
466
|
+
}
|
|
467
|
+
/** @type {Record<string, 'allow'|'confirm'|'block'>} */
|
|
468
|
+
const policy = {}
|
|
469
|
+
for (const [ruleId, decision] of Object.entries(value)) {
|
|
470
|
+
if (ruleId.trim() === '') throw new RegistryError(errorCode, 'policy keys must be non-empty rule ids.')
|
|
471
|
+
if (!isDecisionValue(decision)) {
|
|
472
|
+
throw new RegistryError(errorCode, `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
|
|
473
|
+
}
|
|
474
|
+
policy[ruleId] = decision
|
|
475
|
+
}
|
|
476
|
+
return policy
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* @param {unknown} value
|
|
481
|
+
* @param {RegistryErrorCode} errorCode
|
|
482
|
+
* @returns {string[]}
|
|
483
|
+
*/
|
|
484
|
+
function normalizeDisabledRules(value, errorCode) {
|
|
485
|
+
if (value === undefined) return []
|
|
486
|
+
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'disabledRules must be an array of rule ids.')
|
|
487
|
+
/** @type {string[]} */
|
|
488
|
+
const ids = []
|
|
489
|
+
for (const item of value) {
|
|
490
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
491
|
+
throw new RegistryError(errorCode, 'disabledRules must contain non-empty rule ids.')
|
|
492
|
+
}
|
|
493
|
+
const id = item.trim()
|
|
494
|
+
if (!ids.includes(id)) ids.push(id)
|
|
495
|
+
}
|
|
496
|
+
return ids
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Validate the custom-rule list of one record (doc §4.4.2).
|
|
501
|
+
*
|
|
502
|
+
* Ids are unique per server and must not collide with a built-in id. The
|
|
503
|
+
* decision may only be `confirm` or `block` — a rule that permits what the
|
|
504
|
+
* engine already permits buys nothing.
|
|
505
|
+
* @param {unknown} value
|
|
506
|
+
* @param {RegistryErrorCode} errorCode
|
|
507
|
+
* @returns {import('./policy.js').CustomRule[]}
|
|
508
|
+
*/
|
|
509
|
+
function normalizeCustomRules(value, errorCode) {
|
|
510
|
+
if (value === undefined) return []
|
|
511
|
+
if (!Array.isArray(value)) throw new RegistryError(errorCode, 'customRules must be an array of rules.')
|
|
512
|
+
if (value.length > MAX_CUSTOM_RULES) {
|
|
513
|
+
throw new RegistryError(errorCode, `customRules may hold at most ${MAX_CUSTOM_RULES} rules.`)
|
|
514
|
+
}
|
|
515
|
+
/** @type {import('./policy.js').CustomRule[]} */
|
|
516
|
+
const rules = []
|
|
517
|
+
/** @type {string[]} */
|
|
518
|
+
const seen = []
|
|
519
|
+
for (const item of value) {
|
|
520
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
521
|
+
let rule
|
|
522
|
+
try {
|
|
523
|
+
rule = validateCustomRule(item, { takenIds: seen })
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error instanceof RuleError) throw new RegistryError(errorCode, `customRules: ${error.message}`)
|
|
526
|
+
throw error
|
|
527
|
+
}
|
|
528
|
+
seen.push(rule.id)
|
|
529
|
+
rules.push(rule)
|
|
530
|
+
}
|
|
531
|
+
return rules
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Validate and canonicalise one complete record. Create, update and load all
|
|
536
|
+
* go through this function, so a stored record and a freshly created one have
|
|
537
|
+
* exactly the same shape.
|
|
538
|
+
* @param {Record<string, unknown>} source
|
|
539
|
+
* @param {{
|
|
540
|
+
* allowed: ReadonlySet<string>,
|
|
541
|
+
* id: string,
|
|
542
|
+
* errorCode: RegistryErrorCode,
|
|
543
|
+
* label: string,
|
|
544
|
+
* createdAt?: string,
|
|
545
|
+
* migrate?: boolean,
|
|
546
|
+
* }} options
|
|
547
|
+
* @returns {ServerRecord}
|
|
548
|
+
*/
|
|
549
|
+
function normalizeRecord(source, options) {
|
|
550
|
+
const { allowed, id, errorCode, label } = options
|
|
551
|
+
if (!isPlainObject(source)) throw new RegistryError(errorCode, `${label} must be an object.`)
|
|
552
|
+
assertKnownFields(source, allowed, errorCode, label)
|
|
553
|
+
const createdAt =
|
|
554
|
+
options.createdAt !== undefined
|
|
555
|
+
? normalizeTimestamp(options.createdAt, 'createdAt', errorCode)
|
|
556
|
+
: normalizeTimestamp(source.createdAt, 'createdAt', errorCode)
|
|
557
|
+
return {
|
|
558
|
+
id,
|
|
559
|
+
name: requiredString(source.name, 'name', errorCode),
|
|
560
|
+
host: normalizeHost(source.host, errorCode),
|
|
561
|
+
port: normalizePort(source.port, errorCode),
|
|
562
|
+
username: requiredString(source.username, 'username', errorCode),
|
|
563
|
+
auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
|
|
564
|
+
hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
|
|
565
|
+
defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
|
|
566
|
+
enabled: normalizeEnabled(source.enabled, errorCode),
|
|
567
|
+
policy: normalizePolicy(source.policy, errorCode),
|
|
568
|
+
disabledRules: normalizeDisabledRules(source.disabledRules, errorCode),
|
|
569
|
+
customRules: normalizeCustomRules(source.customRules, errorCode),
|
|
570
|
+
createdAt,
|
|
571
|
+
lastConnectedAt: normalizeOptionalTimestamp(source.lastConnectedAt, 'lastConnectedAt', errorCode),
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Case- and whitespace-insensitive comparison key for the unique display name.
|
|
577
|
+
* @param {string} name
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
function normalizeNameKey(name) {
|
|
581
|
+
return name.trim().toLowerCase()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @param {ServerRecord[]} servers
|
|
586
|
+
* @returns {string}
|
|
587
|
+
*/
|
|
588
|
+
function describeServers(servers) {
|
|
589
|
+
if (servers.length === 0) return 'none configured'
|
|
590
|
+
return servers.map((server) => `${server.id} (${server.name})`).join(', ')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @param {string} id
|
|
595
|
+
* @param {ServerRecord[]} servers
|
|
596
|
+
* @returns {RegistryError}
|
|
597
|
+
*/
|
|
598
|
+
function unknownServer(id, servers) {
|
|
599
|
+
return new RegistryError('UNKNOWN_SERVER', `Unknown server "${id}". Available servers: ${describeServers(servers)}.`, {
|
|
600
|
+
available: servers.map((server) => server.id),
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* @param {ServerRecord[]} servers
|
|
606
|
+
* @param {string} name
|
|
607
|
+
* @param {string|null} exceptId - the record being renamed, or null on create.
|
|
608
|
+
* @returns {void}
|
|
609
|
+
*/
|
|
610
|
+
function assertNameAvailable(servers, name, exceptId) {
|
|
611
|
+
const key = normalizeNameKey(name)
|
|
612
|
+
for (const server of servers) {
|
|
613
|
+
if (server.id === exceptId) continue
|
|
614
|
+
if (normalizeNameKey(server.name) === key) {
|
|
615
|
+
throw new RegistryError(
|
|
616
|
+
'DUPLICATE_NAME',
|
|
617
|
+
`A server named ${JSON.stringify(name)} already exists (id ${server.id}). Server names are unique.`,
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Next sequential id, e.g. `srv-01`. The width grows to fit the number.
|
|
625
|
+
* @param {ServerRecord[]} servers
|
|
626
|
+
* @returns {string}
|
|
627
|
+
*/
|
|
628
|
+
function nextServerId(servers) {
|
|
629
|
+
let highest = 0
|
|
630
|
+
for (const server of servers) {
|
|
631
|
+
const match = /^srv-(\d+)$/.exec(server.id)
|
|
632
|
+
if (match === null) continue
|
|
633
|
+
const digits = match[1]
|
|
634
|
+
if (digits === undefined) continue
|
|
635
|
+
const value = Number(digits)
|
|
636
|
+
if (Number.isSafeInteger(value) && value > highest) highest = value
|
|
637
|
+
}
|
|
638
|
+
const next = highest + 1
|
|
639
|
+
const width = next >= 100 ? String(next).length : 2
|
|
640
|
+
return `srv-${String(next).padStart(width, '0')}`
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Drop undefined patch entries and reject immutable or unknown fields before
|
|
645
|
+
* they are merged into a record.
|
|
646
|
+
* @param {Record<string, unknown>} patch
|
|
647
|
+
* @returns {Record<string, unknown>}
|
|
648
|
+
*/
|
|
649
|
+
function normalizePatch(patch) {
|
|
650
|
+
if (!isPlainObject(patch)) throw new RegistryError('INVALID_SERVER', 'update patch must be an object.')
|
|
651
|
+
/** @type {Record<string, unknown>} */
|
|
652
|
+
const changes = {}
|
|
653
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
654
|
+
if (value === undefined) continue
|
|
655
|
+
if (key === 'id' || key === 'createdAt') {
|
|
656
|
+
throw new RegistryError('INVALID_SERVER', `${key} is immutable; create a new server instead.`)
|
|
657
|
+
}
|
|
658
|
+
if (!UPDATE_FIELDS.has(key)) {
|
|
659
|
+
if (SECRET_FIELDS.has(key)) throw plaintextError(key, 'INVALID_SERVER')
|
|
660
|
+
throw new RegistryError('INVALID_SERVER', `Unknown field "${key}" in update.`)
|
|
661
|
+
}
|
|
662
|
+
changes[key] = value
|
|
663
|
+
}
|
|
664
|
+
return changes
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* CRUD facade over one `servers.json` file.
|
|
669
|
+
*
|
|
670
|
+
* All methods are asynchronous; reads go straight to disk, mutations are
|
|
671
|
+
* queued per file and committed atomically. Use {@link Registry.resolve} for
|
|
672
|
+
* the forgiving server addressing described in doc §5.2.
|
|
673
|
+
*/
|
|
674
|
+
export class Registry {
|
|
675
|
+
/** @type {string} */
|
|
676
|
+
#file
|
|
677
|
+
/**
|
|
678
|
+
* @param {{file?: string}} [options] - `file` overrides the default path and is
|
|
679
|
+
* what the tests use; production code relies on `$DSH_HOME`.
|
|
680
|
+
*/
|
|
681
|
+
constructor(options = {}) {
|
|
682
|
+
this.#file = options.file ?? defaultServersFile()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Absolute path of the registry file this instance reads and writes. */
|
|
686
|
+
get file() {
|
|
687
|
+
return this.#file
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Every server, in file order.
|
|
692
|
+
* @returns {Promise<ServerRecord[]>}
|
|
693
|
+
*/
|
|
694
|
+
async list() {
|
|
695
|
+
const document = await this.#load()
|
|
696
|
+
return document.servers.map((server) => clone(server))
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Exact lookup by stable id.
|
|
701
|
+
* @param {string} id
|
|
702
|
+
* @returns {Promise<ServerRecord|null>} null when no server has that id.
|
|
703
|
+
*/
|
|
704
|
+
async get(id) {
|
|
705
|
+
const document = await this.#load()
|
|
706
|
+
const record = document.servers.find((server) => server.id === id)
|
|
707
|
+
return record === undefined ? null : clone(record)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Forgiving lookup for a tool's `server` argument (doc §5.2):
|
|
712
|
+
* 1. exact `id`;
|
|
713
|
+
* 2. exact `name` (case-insensitive);
|
|
714
|
+
* 3. unique prefix of an id or a name (case-insensitive);
|
|
715
|
+
* 4. zero matches → `UNKNOWN_SERVER`, message listing every server;
|
|
716
|
+
* 5. several matches → `AMBIGUOUS_SERVER`, message listing the candidates.
|
|
717
|
+
*
|
|
718
|
+
* Exact id/name matches win over prefix matches, so a name that prefixes
|
|
719
|
+
* another name remains addressable.
|
|
720
|
+
* @param {string} query
|
|
721
|
+
* @returns {Promise<ServerRecord>}
|
|
722
|
+
* @throws {RegistryError} `INVALID_SERVER`, `UNKNOWN_SERVER` or `AMBIGUOUS_SERVER`.
|
|
723
|
+
*/
|
|
724
|
+
async resolve(query) {
|
|
725
|
+
if (typeof query !== 'string' || query.trim() === '') {
|
|
726
|
+
throw new RegistryError('INVALID_SERVER', 'server must be a non-empty id, name or unique prefix.')
|
|
727
|
+
}
|
|
728
|
+
const wanted = query.trim()
|
|
729
|
+
const document = await this.#load()
|
|
730
|
+
const servers = document.servers
|
|
731
|
+
|
|
732
|
+
const exactId = servers.find((server) => server.id === wanted)
|
|
733
|
+
if (exactId !== undefined) return clone(exactId)
|
|
734
|
+
|
|
735
|
+
const wantedName = wanted.toLowerCase()
|
|
736
|
+
const exactName = servers.find((server) => server.name.toLowerCase() === wantedName)
|
|
737
|
+
if (exactName !== undefined) return clone(exactName)
|
|
738
|
+
|
|
739
|
+
const prefix = wanted.toLowerCase()
|
|
740
|
+
const candidates = servers.filter(
|
|
741
|
+
(server) => server.id.toLowerCase().startsWith(prefix) || server.name.toLowerCase().startsWith(prefix),
|
|
742
|
+
)
|
|
743
|
+
const only = candidates.length === 1 ? candidates[0] : undefined
|
|
744
|
+
if (only !== undefined) return clone(only)
|
|
745
|
+
if (candidates.length === 0) throw unknownServer(wanted, servers)
|
|
746
|
+
throw new RegistryError(
|
|
747
|
+
'AMBIGUOUS_SERVER',
|
|
748
|
+
`Server "${wanted}" is ambiguous: matches ${candidates
|
|
749
|
+
.map((server) => `${server.id} (${server.name})`)
|
|
750
|
+
.join(', ')}. Use the full id or name.`,
|
|
751
|
+
{ candidates: candidates.map((server) => server.id) },
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Create a server. The id is assigned by the registry; `createdAt` is now.
|
|
757
|
+
* @param {Record<string, unknown>} input
|
|
758
|
+
* @returns {Promise<ServerRecord>} the stored record.
|
|
759
|
+
* @throws {RegistryError} `INVALID_SERVER` or `DUPLICATE_NAME`.
|
|
760
|
+
*/
|
|
761
|
+
async create(input) {
|
|
762
|
+
return this.#mutate((document) => {
|
|
763
|
+
const id = nextServerId(document.servers)
|
|
764
|
+
const record = normalizeRecord(input, {
|
|
765
|
+
allowed: CREATE_FIELDS,
|
|
766
|
+
id,
|
|
767
|
+
errorCode: 'INVALID_SERVER',
|
|
768
|
+
label: 'server',
|
|
769
|
+
createdAt: new Date().toISOString(),
|
|
770
|
+
})
|
|
771
|
+
assertNameAvailable(document.servers, record.name, null)
|
|
772
|
+
document.servers.push(record)
|
|
773
|
+
return clone(record)
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Change some fields of one server. `id` and `createdAt` cannot be changed;
|
|
779
|
+
* omitted fields keep their current value. The merged record is validated as
|
|
780
|
+
* a whole before it replaces the stored one.
|
|
781
|
+
* @param {string} id
|
|
782
|
+
* @param {Record<string, unknown>} patch
|
|
783
|
+
* @returns {Promise<ServerRecord>} the stored record.
|
|
784
|
+
* @throws {RegistryError} `UNKNOWN_SERVER`, `INVALID_SERVER` or `DUPLICATE_NAME`.
|
|
785
|
+
*/
|
|
786
|
+
async update(id, patch) {
|
|
787
|
+
return this.#mutate((document) => {
|
|
788
|
+
const index = document.servers.findIndex((server) => server.id === id)
|
|
789
|
+
const existing = index === -1 ? undefined : document.servers[index]
|
|
790
|
+
if (existing === undefined) throw unknownServer(id, document.servers)
|
|
791
|
+
const changes = normalizePatch(patch)
|
|
792
|
+
/** @type {Record<string, unknown>} */
|
|
793
|
+
const merged = { ...existing }
|
|
794
|
+
for (const [key, value] of Object.entries(changes)) merged[key] = value
|
|
795
|
+
const record = normalizeRecord(merged, {
|
|
796
|
+
allowed: STORED_FIELDS,
|
|
797
|
+
id: existing.id,
|
|
798
|
+
errorCode: 'INVALID_SERVER',
|
|
799
|
+
label: 'server',
|
|
800
|
+
createdAt: existing.createdAt,
|
|
801
|
+
})
|
|
802
|
+
assertNameAvailable(document.servers, record.name, existing.id)
|
|
803
|
+
document.servers[index] = record
|
|
804
|
+
return clone(record)
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Delete one server, returning the record that was removed.
|
|
810
|
+
* @param {string} id
|
|
811
|
+
* @returns {Promise<ServerRecord>}
|
|
812
|
+
* @throws {RegistryError} `UNKNOWN_SERVER`.
|
|
813
|
+
*/
|
|
814
|
+
async remove(id) {
|
|
815
|
+
return this.#mutate((document) => {
|
|
816
|
+
const index = document.servers.findIndex((server) => server.id === id)
|
|
817
|
+
const existing = index === -1 ? undefined : document.servers[index]
|
|
818
|
+
if (existing === undefined) throw unknownServer(id, document.servers)
|
|
819
|
+
document.servers.splice(index, 1)
|
|
820
|
+
return clone(existing)
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Read and validate the whole file. A missing or empty file is an empty
|
|
826
|
+
* registry; invalid JSON, an unknown version, duplicate ids/names or a
|
|
827
|
+
* malformed record is reported as `INVALID_REGISTRY` instead of being
|
|
828
|
+
* silently rewritten.
|
|
829
|
+
* @returns {Promise<RegistryDocument>}
|
|
830
|
+
*/
|
|
831
|
+
async #load() {
|
|
832
|
+
/** @type {string} */
|
|
833
|
+
let text
|
|
834
|
+
try {
|
|
835
|
+
text = await readFile(this.#file, 'utf8')
|
|
836
|
+
} catch (error) {
|
|
837
|
+
if (isErrno(error, 'ENOENT')) return { version: REGISTRY_VERSION, servers: [] }
|
|
838
|
+
throw error
|
|
839
|
+
}
|
|
840
|
+
if (text.trim() === '') return { version: REGISTRY_VERSION, servers: [] }
|
|
841
|
+
|
|
842
|
+
/** @type {unknown} */
|
|
843
|
+
let parsed
|
|
844
|
+
try {
|
|
845
|
+
parsed = JSON.parse(text)
|
|
846
|
+
} catch {
|
|
847
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file} is not valid JSON; fix or remove it.`)
|
|
848
|
+
}
|
|
849
|
+
if (!isPlainObject(parsed)) {
|
|
850
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a JSON object.`)
|
|
851
|
+
}
|
|
852
|
+
if (parsed.version !== REGISTRY_VERSION) {
|
|
853
|
+
throw new RegistryError(
|
|
854
|
+
'INVALID_REGISTRY',
|
|
855
|
+
`${this.#file} has unsupported version ${String(parsed.version)}; expected ${REGISTRY_VERSION}.`,
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
if (!Array.isArray(parsed.servers)) {
|
|
859
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file} must contain a "servers" array.`)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** @type {ServerRecord[]} */
|
|
863
|
+
const servers = []
|
|
864
|
+
const seenIds = new Set()
|
|
865
|
+
const seenNames = new Set()
|
|
866
|
+
for (let index = 0; index < parsed.servers.length; index += 1) {
|
|
867
|
+
const raw = parsed.servers[index]
|
|
868
|
+
if (!isPlainObject(raw)) {
|
|
869
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: servers[${index}] is not an object.`)
|
|
870
|
+
}
|
|
871
|
+
const recordId = requiredString(raw.id, `servers[${index}].id`, 'INVALID_REGISTRY')
|
|
872
|
+
const record = normalizeRecord(raw, {
|
|
873
|
+
allowed: STORED_FIELDS,
|
|
874
|
+
id: recordId,
|
|
875
|
+
errorCode: 'INVALID_REGISTRY',
|
|
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,
|
|
880
|
+
})
|
|
881
|
+
if (seenIds.has(record.id)) {
|
|
882
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
|
|
883
|
+
}
|
|
884
|
+
seenIds.add(record.id)
|
|
885
|
+
const nameKey = normalizeNameKey(record.name)
|
|
886
|
+
if (seenNames.has(nameKey)) {
|
|
887
|
+
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server name ${record.name}.`)
|
|
888
|
+
}
|
|
889
|
+
seenNames.add(nameKey)
|
|
890
|
+
servers.push(record)
|
|
891
|
+
}
|
|
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
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Commit the document by writing a sibling temp file and renaming it over
|
|
922
|
+
* the registry. Readers only ever observe the old or the new complete file.
|
|
923
|
+
* @param {RegistryDocument} document
|
|
924
|
+
* @returns {Promise<void>}
|
|
925
|
+
*/
|
|
926
|
+
async #save(document) {
|
|
927
|
+
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
928
|
+
const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
929
|
+
try {
|
|
930
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
931
|
+
await rename(temporary, this.#file)
|
|
932
|
+
} catch (error) {
|
|
933
|
+
await unlink(temporary).catch(() => {})
|
|
934
|
+
throw error
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Run one read-modify-write operation after every earlier mutation finished.
|
|
940
|
+
*
|
|
941
|
+
* The queue is keyed by the absolute file path, not by this instance: the
|
|
942
|
+
* tools surface, the HTTP routes and the tests each build their own Registry,
|
|
943
|
+
* and two instances on the same file must not lose each other's writes.
|
|
944
|
+
* @template T
|
|
945
|
+
* @param {(document: RegistryDocument) => T} mutate
|
|
946
|
+
* @returns {Promise<T>}
|
|
947
|
+
*/
|
|
948
|
+
async #mutate(mutate) {
|
|
949
|
+
const key = resolve(this.#file)
|
|
950
|
+
const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
|
|
951
|
+
const operation = previous.then(async () => {
|
|
952
|
+
const document = await this.#load()
|
|
953
|
+
const result = mutate(document)
|
|
954
|
+
await this.#save(document)
|
|
955
|
+
return result
|
|
956
|
+
})
|
|
957
|
+
const settled = operation.then(
|
|
958
|
+
() => undefined,
|
|
959
|
+
() => undefined,
|
|
960
|
+
)
|
|
961
|
+
MUTATION_QUEUES.set(key, settled)
|
|
962
|
+
// Keep the map from growing once a file goes quiet.
|
|
963
|
+
void settled.then(() => {
|
|
964
|
+
if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
|
|
965
|
+
})
|
|
966
|
+
return operation
|
|
967
|
+
}
|
|
968
|
+
}
|