weir-harness 2.0.0 → 2.1.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.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +3 -3
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +368 -142
  24. package/src/capabilities/mcp-mount.js +219 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +161 -56
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -0,0 +1,344 @@
1
+ // One MCP configuration parser/projection for forms, JSON paste, commands, AI,
2
+ // registry previews and stock-client materialization. Unknown fields fail before
3
+ // any operation side effect; secret values never appear in the returned plan.
4
+ import { createHash } from 'node:crypto'
5
+ import { isSegment } from './store/paths.js'
6
+ import { sanitizeMcpConfigText, markMcpConfigProjection } from './mcp-diagnostics.js'
7
+
8
+ export const MCP_LIMITS = Object.freeze({
9
+ documentBytes: 1024 * 1024,
10
+ batchEntries: 32,
11
+ args: 128,
12
+ env: 64,
13
+ headers: 64,
14
+ planBytes: 256 * 1024,
15
+ toolNamesPreview: 20,
16
+ diagnosticBytes: 8192,
17
+ })
18
+ const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
19
+ const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
20
+ const COMMON = new Set(['transport', 'type', 'serverName', 'toolCallTimeoutMs', 'maxInstructionBytes', 'reconnect', 'failOnStartupError'])
21
+ const ROOT = new Set(['identity', 'label', 'owner', 'incarnation', 'generation', 'configuredAt', 'client', 'transport', 'secretRefs', 'secretValues', ...COMMON, 'command', 'args', 'env', 'cwd', 'url', 'headers', 'secretArgs'])
22
+ const RECONNECT = new Set(['enabled', 'initialDelayMs', 'maxDelayMs', 'maxAttempts'])
23
+ const clone = value => value == null ? value : structuredClone(value)
24
+ const object = value => value && typeof value === 'object' && !Array.isArray(value)
25
+
26
+ export class McpConfigError extends TypeError {
27
+ constructor(field, message) {
28
+ super(`${field}: ${message}`)
29
+ this.name = 'McpConfigError'
30
+ this.code = 'invalid-config'
31
+ this.field = field
32
+ }
33
+ }
34
+ const fail = (field, message) => { throw new McpConfigError(field, message) }
35
+ const known = (value, allowed, path) => {
36
+ for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${path}.${key}`, 'unknown field')
37
+ }
38
+ const positive = (value, field, integer = false) => {
39
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || (integer && !Number.isSafeInteger(value))) fail(field, 'must be a positive finite number')
40
+ return value
41
+ }
42
+ const ref = (value, field) => {
43
+ if (!object(value) || typeof value.ref !== 'string' || !CREDENTIAL_REF.test(value.ref)) fail(field, 'must be a POSIX credential reference')
44
+ known(value, new Set(['ref', 'prefix']), field)
45
+ if (value.prefix !== undefined && typeof value.prefix !== 'string') fail(`${field}.prefix`, 'must be a string')
46
+ return { ref: value.ref, ...(value.prefix ? { prefix: value.prefix } : {}) }
47
+ }
48
+ const sensitiveUrl = value => {
49
+ try {
50
+ const url = new URL(value)
51
+ return Boolean(url.username || url.password || url.search || url.hash)
52
+ } catch { return true }
53
+ }
54
+ const safeHttpUrl = (value, field) => {
55
+ let url
56
+ try { url = new URL(value) } catch { fail(field, 'must be an absolute URL') }
57
+ if (!['http:', 'https:'].includes(url.protocol)) fail(field, 'only http/https URLs are supported')
58
+ return url
59
+ }
60
+
61
+ function reconnectOf(value) {
62
+ if (value === undefined) return undefined
63
+ if (!object(value)) fail('reconnect', 'must be an object')
64
+ known(value, RECONNECT, 'reconnect')
65
+ const out = {}
66
+ if (value.enabled !== undefined) {
67
+ if (typeof value.enabled !== 'boolean') fail('reconnect.enabled', 'must be boolean')
68
+ out.enabled = value.enabled
69
+ }
70
+ for (const key of ['initialDelayMs', 'maxDelayMs']) if (value[key] !== undefined) out[key] = positive(value[key], `reconnect.${key}`)
71
+ if (value.maxAttempts !== undefined) out.maxAttempts = positive(value.maxAttempts, 'reconnect.maxAttempts', true)
72
+ if ((out.initialDelayMs ?? 500) > (out.maxDelayMs ?? 30000)) fail('reconnect.initialDelayMs', 'must not exceed maxDelayMs')
73
+ return out
74
+ }
75
+
76
+ function secretInput(value, field, surface) {
77
+ if (!object(value)) {
78
+ if (value === '') return { action: 'keep' }
79
+ if (surface !== 'ui') fail(field, 'literal secrets are accepted only by the Host mutation channel; use a credential reference')
80
+ if (typeof value !== 'string') fail(field, 'secret values must be strings')
81
+ return { action: 'replace', value }
82
+ }
83
+ known(value, new Set(['action', 'value', 'prefix']), field)
84
+ const action = value.action ?? 'replace'
85
+ if (value.prefix !== undefined && typeof value.prefix !== 'string') fail(`${field}.prefix`, 'must be a string')
86
+ if (!['keep', 'replace', 'remove'].includes(action)) fail(`${field}.action`, 'must be keep, replace or remove')
87
+ if (action === 'replace') {
88
+ if (surface !== 'ui') fail(field, 'literal secrets are accepted only by the Host mutation channel; use a credential reference')
89
+ if (typeof value.value !== 'string' || value.value.length === 0) fail(`${field}.value`, 'replacement secrets must be non-empty strings; blank means keep')
90
+ }
91
+ return { action, ...(action === 'replace' ? { value: value.value, ...(value.prefix !== undefined ? { prefix: value.prefix } : {}) } : {}) }
92
+ }
93
+
94
+ function secretMap(input, field, limit, surface) {
95
+ if (input === undefined) return { refs: {}, values: {}, literals: {}, legacy: {} }
96
+ if (!object(input)) fail(field, 'must be an object')
97
+ const entries = Object.entries(input)
98
+ if (entries.length > limit) fail(field, `exceeds the ${limit}-entry limit`)
99
+ const refs = {}, values = {}, literals = {}, legacy = {}
100
+ for (const [key, value] of entries) {
101
+ if (!key || Buffer.byteLength(key) > 256) fail(field, 'contains an invalid key')
102
+ if (typeof value === 'string') {
103
+ // New UI/JSON literal env/header values are staged credentials. The
104
+ // registry adapter may explicitly preserve a legacy record instead.
105
+ if (surface === 'legacy') legacy[key] = value
106
+ else if (surface === 'refs' && value !== '') fail(`${field}.${key}`, 'literal secrets are not accepted; use a credential reference')
107
+ else values[key] = value === '' ? { action: 'keep' } : { action: 'replace', value }
108
+ } else if (object(value) && typeof value.ref === 'string') refs[key] = ref(value, `${field}.${key}`)
109
+ else values[key] = secretInput(value, `${field}.${key}`, surface)
110
+ }
111
+ return { refs, values, literals, legacy }
112
+ }
113
+
114
+ function refsOf(input, field) {
115
+ if (!object(input)) return {}
116
+ known(input, new Set(['env', 'headers', 'args', 'url']), field)
117
+ const out = {}
118
+ for (const kind of ['env', 'headers', 'args']) {
119
+ if (input[kind] === undefined) continue
120
+ if (!object(input[kind])) fail(`${field}.${kind}`, 'must be an object')
121
+ out[kind] = {}
122
+ for (const [key, value] of Object.entries(input[kind])) out[kind][key] = ref(value, `${field}.${kind}.${key}`)
123
+ }
124
+ if (input.url !== undefined) out.url = ref(input.url, `${field}.url`)
125
+ return out
126
+ }
127
+
128
+ function normalizeTransport(input, client) {
129
+ const kind = client.transport ?? client.type ?? input.transport?.kind ?? (typeof client.command === 'string' ? 'stdio' : typeof client.url === 'string' || object(client.url) ? 'streamable-http' : undefined)
130
+ if (kind === 'http') return 'streamable-http'
131
+ if (kind !== 'stdio' && kind !== 'streamable-http') fail('transport', 'must be stdio or streamable-http')
132
+ return kind
133
+ }
134
+
135
+ /** Normalize one server. surface=ui permits transient secretValues; refs-only surfaces never do. */
136
+ export function normalizeMcpServer(raw, options = {}) {
137
+ if (!object(raw)) fail('server', 'must be an object')
138
+ known(raw, ROOT, 'server')
139
+ const surface = options.surface ?? 'ui'
140
+ if (!['ui', 'refs', 'legacy'].includes(surface)) fail('surface', 'unknown input surface')
141
+ const clientInput = raw.client ?? raw
142
+ if (!object(clientInput)) fail('client', 'must be an object')
143
+ const kind = normalizeTransport(raw, clientInput)
144
+ const allowed = new Set(kind === 'stdio' ? ['command', 'args', 'env', 'cwd', 'secretArgs'] : ['url', 'headers'])
145
+ known(clientInput, new Set([...COMMON, ...allowed, 'identity', 'label', 'owner', 'incarnation', 'generation', 'configuredAt', 'client', 'secretRefs', 'secretValues']), 'client')
146
+ const identity = options.identity ?? raw.identity
147
+ if (!isSegment(identity)) fail('identity', 'must be a stable identity segment')
148
+ const label = raw.label ?? identity
149
+ if (typeof label !== 'string' || !label.trim() || Buffer.byteLength(label) > 512) fail('label', 'must be a non-empty bounded string')
150
+ const serverName = clientInput.serverName ?? identity
151
+ if (typeof serverName !== 'string' || !SERVER_NAME.test(serverName)) fail('serverName', 'must match [A-Za-z0-9_-]{1,32}')
152
+ const client = { transport: kind, serverName, failOnStartupError: true }
153
+ for (const key of ['toolCallTimeoutMs', 'maxInstructionBytes']) if (clientInput[key] !== undefined) client[key] = positive(clientInput[key], key, key === 'maxInstructionBytes')
154
+ const reconnect = reconnectOf(clientInput.reconnect)
155
+ if (reconnect) client.reconnect = reconnect
156
+ const secretRefs = { env: {}, headers: {}, args: {} }
157
+ const secretValues = { env: {}, headers: {}, args: {} }
158
+ const legacy = { env: {}, headers: {} }
159
+ const existingRefs = refsOf(raw.secretRefs, 'secretRefs')
160
+ Object.assign(secretRefs.env, existingRefs.env ?? {})
161
+ Object.assign(secretRefs.headers, existingRefs.headers ?? {})
162
+ Object.assign(secretRefs.args, existingRefs.args ?? {})
163
+ if (existingRefs.url) secretRefs.url = existingRefs.url
164
+
165
+ if (kind === 'stdio') {
166
+ if (clientInput.url !== undefined || clientInput.headers !== undefined) fail('client', 'HTTP fields cannot be combined with stdio')
167
+ if (typeof clientInput.command !== 'string' || !clientInput.command.trim()) fail('command', 'is required')
168
+ client.command = clientInput.command
169
+ const args = clientInput.args ?? []
170
+ if (!Array.isArray(args) || args.length > MCP_LIMITS.args) fail('args', `must be an array of at most ${MCP_LIMITS.args} entries`)
171
+ client.args = args.map((value, index) => {
172
+ if (typeof value === 'string') return value
173
+ if (object(value) && typeof value.ref === 'string') {
174
+ secretRefs.args[index] = ref(value, `args.${index}`)
175
+ return ''
176
+ }
177
+ fail(`args.${index}`, 'must be a string or credential reference')
178
+ })
179
+ if (clientInput.secretArgs !== undefined) {
180
+ if (!object(clientInput.secretArgs)) fail('secretArgs', 'must be an object keyed by argument index')
181
+ for (const [key, value] of Object.entries(clientInput.secretArgs)) {
182
+ const index = Number(key)
183
+ if (!Number.isInteger(index) || index < 0 || index >= client.args.length) fail(`secretArgs.${key}`, 'argument index is out of range')
184
+ if (object(value) && typeof value.ref === 'string') secretRefs.args[index] = ref(value, `secretArgs.${key}`)
185
+ else secretValues.args[index] = secretInput(value, `secretArgs.${key}`, surface)
186
+ }
187
+ }
188
+ const env = secretMap(clientInput.env, 'env', MCP_LIMITS.env, surface)
189
+ Object.assign(secretRefs.env, env.refs)
190
+ Object.assign(secretValues.env, env.values)
191
+ Object.assign(legacy.env, env.legacy)
192
+ if (clientInput.cwd !== undefined) {
193
+ if (typeof clientInput.cwd !== 'string') fail('cwd', 'must be a string')
194
+ client.cwd = clientInput.cwd
195
+ }
196
+ } else {
197
+ if (clientInput.command !== undefined || clientInput.args !== undefined || clientInput.env !== undefined || clientInput.cwd !== undefined || clientInput.secretArgs !== undefined) fail('client', 'stdio fields cannot be combined with streamable-http')
198
+ if (object(clientInput.url) && typeof clientInput.url.ref === 'string') secretRefs.url = ref(clientInput.url, 'url')
199
+ else if (typeof clientInput.url === 'string') {
200
+ const url = safeHttpUrl(clientInput.url, 'url')
201
+ if (sensitiveUrl(clientInput.url)) {
202
+ if (surface !== 'ui') fail('url', 'sensitive URLs require a credential reference')
203
+ secretValues.url = { action: 'replace', value: clientInput.url }
204
+ } else client.url = url.toString()
205
+ } else if (clientInput.url !== undefined) secretValues.url = secretInput(clientInput.url, 'url', surface)
206
+ else if (!secretRefs.url) fail('url', 'is required')
207
+ const headers = secretMap(clientInput.headers, 'headers', MCP_LIMITS.headers, surface)
208
+ Object.assign(secretRefs.headers, headers.refs)
209
+ Object.assign(secretValues.headers, headers.values)
210
+ Object.assign(legacy.headers, headers.legacy)
211
+ }
212
+ if (raw.secretValues !== undefined) {
213
+ if (!object(raw.secretValues)) fail('secretValues', 'must be an object')
214
+ known(raw.secretValues, new Set(['env', 'headers', 'args', 'url']), 'secretValues')
215
+ for (const [field, limit] of [['env', MCP_LIMITS.env], ['headers', MCP_LIMITS.headers], ['args', MCP_LIMITS.args]]) {
216
+ const values = raw.secretValues[field]
217
+ if (values === undefined) continue
218
+ if (!object(values)) fail(`secretValues.${field}`, 'must be an object')
219
+ if (Object.keys(values).length > limit) fail(`secretValues.${field}`, `exceeds the ${limit}-entry limit`)
220
+ for (const [key, value] of Object.entries(values)) secretValues[field][key] = secretInput(value, `secretValues.${field}.${key}`, surface)
221
+ }
222
+ if (raw.secretValues.url !== undefined) secretValues.url = secretInput(raw.secretValues.url, 'secretValues.url', surface)
223
+ }
224
+ const hasRefs = Object.values(secretRefs).some(value => object(value) && Object.keys(value).length > 0)
225
+ const hasLegacy = Object.keys(legacy.env).length > 0 || Object.keys(legacy.headers).length > 0
226
+ const entry = {
227
+ identity,
228
+ label,
229
+ owner: clone(raw.owner ?? options.owner ?? { kind: 'global', key: 'installation' }),
230
+ ...(raw.incarnation !== undefined ? { incarnation: raw.incarnation } : {}),
231
+ generation: raw.generation ?? 0,
232
+ configuredAt: raw.configuredAt ?? 0,
233
+ transport: { kind, ref: kind === 'stdio' ? `cmd:${client.command}` : client.url ? `http:${client.url}` : 'http:credential-ref' },
234
+ client: { ...client, ...(kind === 'stdio' ? { env: legacy.env } : { headers: legacy.headers }) },
235
+ secretRefs,
236
+ }
237
+ if (Object.keys(secretRefs.env).length === 0) delete entry.secretRefs.env
238
+ if (Object.keys(secretRefs.headers).length === 0) delete entry.secretRefs.headers
239
+ if (Object.keys(secretRefs.args).length === 0) delete entry.secretRefs.args
240
+ if (Object.keys(entry.secretRefs).length === 0) delete entry.secretRefs
241
+ const transient = Object.keys(secretValues.env).length || Object.keys(secretValues.headers).length || Object.keys(secretValues.args).length || secretValues.url
242
+ return {
243
+ entry,
244
+ secretValues: transient ? secretValues : {},
245
+ hasSecrets: Boolean(hasRefs || hasLegacy || transient),
246
+ legacyLiteralSecrets: hasLegacy,
247
+ }
248
+ }
249
+
250
+ export function parseMcpDocument(input, options = {}) {
251
+ let document = input
252
+ if (typeof input === 'string') {
253
+ if (Buffer.byteLength(input) > MCP_LIMITS.documentBytes) fail('document', `exceeds the ${MCP_LIMITS.documentBytes}-byte limit`)
254
+ try { document = JSON.parse(input) } catch { fail('document', 'must be valid JSON') }
255
+ }
256
+ if (!object(document)) fail('document', 'must be an object')
257
+ const batch = Object.hasOwn(document, 'mcpServers')
258
+ if (batch) {
259
+ known(document, new Set(['mcpServers']), 'document')
260
+ if (!object(document.mcpServers)) fail('mcpServers', 'must be an object')
261
+ }
262
+ const entries = batch ? Object.entries(document.mcpServers) : [[undefined, document]]
263
+ if (entries.length < 1 || entries.length > MCP_LIMITS.batchEntries) fail('mcpServers', `must contain between 1 and ${MCP_LIMITS.batchEntries} servers`)
264
+ const identities = new Set()
265
+ const normalized = entries.map(([key, value], index) => {
266
+ const identity = key ?? value?.identity
267
+ if (identities.has(identity)) fail(`mcpServers.${identity}`, 'duplicate identity')
268
+ identities.add(identity)
269
+ try {
270
+ return normalizeMcpServer(value, { ...options, identity: identity ?? options.identity })
271
+ } catch (error) {
272
+ if (error instanceof McpConfigError) throw new McpConfigError(batch ? `mcpServers.${key ?? index}.${error.field}` : error.field, error.message.slice(error.message.indexOf(': ') + 2))
273
+ throw error
274
+ }
275
+ })
276
+ const plan = normalized.map((item, index) => ({ index, identity: item.entry.identity, entry: redactMcpEntry(item.entry), fingerprint: fingerprintMcpEntry(item.entry) }))
277
+ assertMcpPlanSize(plan)
278
+ const result = { plan, digest: createHash('sha256').update(JSON.stringify(plan)).digest('hex') }
279
+ // Execution-only normalized entries (including transient secretValues) stay
280
+ // reachable to Host operations but can never leak into a serialized preview.
281
+ Object.defineProperty(result, 'entries', { value: normalized, enumerable: false })
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Display/edit projection of a stored entry. Only structurally secret positions
287
+ * are replaced: env/header values, secretRefs-indexed args and sensitive URLs.
288
+ * Every other string keeps its exact value apart from known secrets and
289
+ * sensitive URL tails, so plain argv such as `--port=8080` or `FOO=bar` survives
290
+ * an edit round-trip and an approval card byte-identical.
291
+ */
292
+ export function redactMcpEntry(entry, secrets = []) {
293
+ const out = clone(entry)
294
+ if (out?.client?.env) for (const key of Object.keys(out.client.env)) out.client.env[key] = MCP_REDACTED
295
+ if (out?.client?.headers) for (const key of Object.keys(out.client.headers)) out.client.headers[key] = MCP_REDACTED
296
+ for (const index of Object.keys(out?.secretRefs?.args ?? {})) if (out.client?.args?.[index] !== undefined) out.client.args[index] = MCP_REDACTED
297
+ if (out?.client?.url && sensitiveUrl(out.client.url)) out.client.url = '[redacted-url]'
298
+ delete out.secretValues
299
+ return markMcpConfigProjection(sanitizeMcpConfigProjection(out, secrets))
300
+ }
301
+ function sanitizeMcpConfigProjection(value, secrets) {
302
+ if (typeof value === 'string') return sanitizeMcpConfigText(value, secrets)
303
+ if (Array.isArray(value)) return value.map(item => sanitizeMcpConfigProjection(item, secrets))
304
+ if (object(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeMcpConfigProjection(item, secrets)]))
305
+ return value
306
+ }
307
+ export const MCP_REDACTED = '[redacted]'
308
+ const PLACEHOLDER = /\[redacted(?:-[a-z]+)?\]/
309
+
310
+ /**
311
+ * Edit input for stdio args is resolved against the stored record: an element
312
+ * equal to the redaction projection at the same index means "unchanged" and
313
+ * takes the stored value back (secret slots stay '' with their refs). Any other
314
+ * placeholder is a redacted value round-tripped as literal input and is refused
315
+ * instead of being saved.
316
+ */
317
+ export function resolveMcpEditArgs(existing, incoming) {
318
+ if (!Array.isArray(incoming)) return incoming
319
+ const stored = existing?.client?.args ?? []
320
+ const projected = redactMcpEntry(existing)?.client?.args ?? []
321
+ return incoming.map((value, index) => {
322
+ if (typeof value !== 'string') return value
323
+ if (index < stored.length && value === projected[index]) return stored[index]
324
+ if (PLACEHOLDER.test(value)) fail(`args.${index}`, 'contains a redaction placeholder; omit args to keep the stored arguments or enter the real value')
325
+ return value
326
+ })
327
+ }
328
+
329
+ /** Hash the full record (including legacy literals) without returning those literals. */
330
+ export function fingerprintMcpEntry(entry) {
331
+ return createHash('sha256').update(JSON.stringify(entry ?? null)).digest('hex')
332
+ }
333
+ export function assertMcpPlanSize(plan) {
334
+ const bytes = Buffer.byteLength(JSON.stringify(plan))
335
+ if (bytes > MCP_LIMITS.planBytes) fail('plan', `exceeds the ${MCP_LIMITS.planBytes}-byte limit`)
336
+ return bytes
337
+ }
338
+ export function hasMcpSecrets(entry) {
339
+ if (entry?.secretRefs && Object.keys(entry.secretRefs).length > 0) return true
340
+ if (Object.keys(entry?.client?.env ?? {}).length > 0 || Object.keys(entry?.client?.headers ?? {}).length > 0) return true
341
+ return typeof entry?.client?.url === 'string' && sensitiveUrl(entry.client.url)
342
+ }
343
+ export function isSensitiveMcpUrl(value) { return sensitiveUrl(value) }
344
+ export function validateCredentialRef(value) { return typeof value === 'string' && CREDENTIAL_REF.test(value) }
@@ -0,0 +1,180 @@
1
+ // Attempt-scoped DSH credential allocation, materialization and conservative
2
+ // compensation. Only random Weir-owned refs are ever unset; unknown registry
3
+ // outcomes retain credentials and report indeterminate rather than guessing.
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { McpLifecycleError } from './mcp-diagnostics.js'
6
+ import { validateCredentialRef } from './mcp-config.js'
7
+
8
+ const hash = value => createHash('sha256').update(String(value)).digest('hex').slice(0, 12)
9
+ const clone = value => value == null ? value : structuredClone(value)
10
+ const kinds = ['env', 'headers', 'args']
11
+
12
+ export function collectMcpCredentialRefs(entry) {
13
+ const refs = new Set()
14
+ for (const kind of kinds) for (const value of Object.values(entry?.secretRefs?.[kind] ?? {})) if (value?.ref) refs.add(value.ref)
15
+ if (entry?.secretRefs?.url?.ref) refs.add(entry.secretRefs.url.ref)
16
+ return [...refs]
17
+ }
18
+
19
+ function usedRefs(registryRecord) {
20
+ const used = new Set()
21
+ if (registryRecord?.kind !== 'ok') return used
22
+ for (const entry of Object.values(registryRecord.servers ?? {})) for (const ref of collectMcpCredentialRefs(entry)) used.add(ref)
23
+ return used
24
+ }
25
+
26
+ function credentialName(identity, generation, attemptId, field) {
27
+ return `WEIR_MCP_${hash(identity)}_G${generation}_${attemptId.replaceAll('-', '').slice(0, 16)}_${hash(field)}`
28
+ }
29
+
30
+ /** Allocate unique refs without writing them. A fresh attempt never shares a compensation target. */
31
+ export function allocateMcpCredentialAttempt({ identity, generation, attemptId = randomUUID().replaceAll('-', '') }) {
32
+ if (!/^[a-f0-9-]{8,64}$/i.test(attemptId)) throw new TypeError('invalid MCP credential attempt id')
33
+ const ownedRefs = new Set()
34
+ return {
35
+ id: attemptId,
36
+ ownedRefs,
37
+ refFor(field) {
38
+ const ref = credentialName(identity, generation, attemptId, field)
39
+ ownedRefs.add(ref)
40
+ return ref
41
+ },
42
+ }
43
+ }
44
+
45
+ async function resolveValue(credentials, reference) {
46
+ if (!validateCredentialRef(reference)) throw new McpLifecycleError('credential-missing', new Error('invalid credential reference'), { status: 'validation-failed', action: 'configure-credential' })
47
+ let resolved
48
+ try { resolved = await credentials?.resolve?.(reference) } catch (cause) {
49
+ throw new McpLifecycleError('credential-missing', new Error(`credential ${reference} could not be resolved`, { cause }), { status: 'validation-failed', action: 'configure-credential' })
50
+ }
51
+ const value = typeof resolved === 'string' ? resolved : resolved?.value
52
+ if (typeof value !== 'string' || value.length === 0) throw new McpLifecycleError('credential-missing', new Error(`credential ${reference} is missing or empty`), { status: 'validation-failed', action: 'configure-credential' })
53
+ return value
54
+ }
55
+
56
+ /** Resolve refs on every probe/mount/restart; values remain in Host memory only. */
57
+ export async function materializeMcpClientConfig(entry, credentials) {
58
+ const client = clone(entry?.client ?? {})
59
+ const resolvedSecrets = []
60
+ const resolve = async reference => {
61
+ const value = await resolveValue(credentials, reference)
62
+ resolvedSecrets.push(value)
63
+ return value
64
+ }
65
+ for (const [key, item] of Object.entries(entry?.secretRefs?.env ?? {})) {
66
+ client.env ??= {}
67
+ client.env[key] = `${item.prefix ?? ''}${await resolve(item.ref)}`
68
+ }
69
+ for (const [key, item] of Object.entries(entry?.secretRefs?.headers ?? {})) {
70
+ client.headers ??= {}
71
+ client.headers[key] = `${item.prefix ?? ''}${await resolve(item.ref)}`
72
+ }
73
+ for (const [key, item] of Object.entries(entry?.secretRefs?.args ?? {})) {
74
+ const index = Number(key)
75
+ if (!Number.isInteger(index) || index < 0 || index >= (client.args?.length ?? 0)) throw new McpLifecycleError('invalid-config', new Error(`secret argument ${key} is out of range`), { status: 'validation-failed' })
76
+ client.args[index] = `${item.prefix ?? ''}${await resolve(item.ref)}`
77
+ }
78
+ if (entry?.secretRefs?.url?.ref) client.url = await resolve(entry.secretRefs.url.ref)
79
+ client.failOnStartupError = true
80
+ delete client.secretRefs
81
+ return { client, resolvedSecrets }
82
+ }
83
+
84
+ /**
85
+ * Apply keep/replace/remove secret actions and stage newly allocated refs.
86
+ * The returned entry contains no transient values. On a partial set failure the
87
+ * function compensates only after a registry reread proves each ref unused.
88
+ */
89
+ export async function stageMcpCredentials({ entry, secretValues = {}, credentials, registry, generation, attemptId }) {
90
+ const next = clone(entry)
91
+ next.secretRefs ??= {}
92
+ const attempt = allocateMcpCredentialAttempt({ identity: next.identity, generation, attemptId })
93
+ const writes = []
94
+ const ensureWritable = () => {
95
+ if (typeof credentials?.set !== 'function') throw new McpLifecycleError('unsupported', new Error('DSH credentials are unavailable for new secret values'), { status: 'unsupported', action: 'use-credential-refs' })
96
+ }
97
+ const actionsFor = (kind, key) => {
98
+ const value = secretValues?.[kind]?.[key]
99
+ return typeof value === 'string' ? (value === '' ? { action: 'keep' } : { action: 'replace', value }) : value
100
+ }
101
+ for (const kind of kinds) {
102
+ const keys = new Set([...Object.keys(secretValues?.[kind] ?? {})])
103
+ for (const key of keys) {
104
+ const action = actionsFor(kind, key)
105
+ if (!action || action.action === 'keep') continue
106
+ if (action.action === 'remove') {
107
+ delete next.secretRefs?.[kind]?.[key]
108
+ if (kind === 'env' && next.client?.env) delete next.client.env[key]
109
+ if (kind === 'headers' && next.client?.headers) delete next.client.headers[key]
110
+ if (kind === 'args' && next.client?.args?.[key] !== undefined) next.client.args[key] = ''
111
+ continue
112
+ }
113
+ if (action.action !== 'replace') throw new McpLifecycleError('invalid-config', new Error(`unknown secret action for ${kind}.${key}`), { status: 'validation-failed' })
114
+ ensureWritable()
115
+ const reference = attempt.refFor(`${kind}.${key}`)
116
+ const prefix = Object.hasOwn(action, 'prefix') ? action.prefix : next.secretRefs?.[kind]?.[key]?.prefix
117
+ next.secretRefs[kind] ??= {}
118
+ next.secretRefs[kind][key] = { ref: reference, ...(prefix ? { prefix } : {}) }
119
+ if (kind === 'env' && next.client?.env) delete next.client.env[key]
120
+ if (kind === 'headers' && next.client?.headers) delete next.client.headers[key]
121
+ if (kind === 'args' && next.client?.args?.[key] !== undefined) next.client.args[key] = ''
122
+ writes.push({ ref: reference, value: action.value })
123
+ }
124
+ }
125
+ const urlAction = typeof secretValues.url === 'string' ? { action: 'replace', value: secretValues.url } : secretValues.url
126
+ if (urlAction && urlAction.action !== 'keep') {
127
+ if (urlAction.action === 'remove') {
128
+ delete next.secretRefs.url
129
+ delete next.client.url
130
+ } else if (urlAction.action === 'replace') {
131
+ ensureWritable()
132
+ const reference = attempt.refFor('url')
133
+ next.secretRefs.url = { ref: reference }
134
+ delete next.client.url
135
+ writes.push({ ref: reference, value: urlAction.value })
136
+ } else throw new McpLifecycleError('invalid-config', new Error('unknown secret action for url'), { status: 'validation-failed' })
137
+ }
138
+ for (const kind of kinds) if (next.secretRefs?.[kind] && Object.keys(next.secretRefs[kind]).length === 0) delete next.secretRefs[kind]
139
+ if (next.secretRefs && Object.keys(next.secretRefs).length === 0) delete next.secretRefs
140
+ try {
141
+ for (const write of writes) await credentials.set(write.ref, write.value)
142
+ Object.defineProperty(attempt, 'secrets', { value: Object.freeze(writes.map(write => write.value)), enumerable: false })
143
+ return { status: 'staged', entry: next, attempt }
144
+ } catch (cause) {
145
+ const cleanup = await compensateMcpCredentialAttempt({ attempt, registry, credentials, provenUncommitted: true })
146
+ throw new McpLifecycleError(cleanup.status === 'cleaned' ? 'credential-write-failed' : cleanup.status, cause, {
147
+ status: cleanup.status === 'cleaned' ? 'validation-failed' : cleanup.status,
148
+ cleanup,
149
+ action: cleanup.status === 'cleaned' ? 'retry' : 'inspect-credentials',
150
+ secrets: writes.map(write => write.value),
151
+ })
152
+ }
153
+ }
154
+
155
+ /** Conservative compensation; never deletes user refs or performs orphan GC. */
156
+ export async function compensateMcpCredentialAttempt({ attempt, registry, credentials, provenUncommitted }) {
157
+ if (!attempt || attempt.ownedRefs.size === 0) return { status: 'cleaned', unset: [] }
158
+ if (!provenUncommitted) return { status: 'indeterminate', retained: [...attempt.ownedRefs], reason: 'registry-outcome-unknown' }
159
+ let current
160
+ try { current = await registry.read() } catch (cause) {
161
+ return { status: 'indeterminate', retained: [...attempt.ownedRefs], reason: 'registry-reread-failed', diagnostic: String(cause?.message ?? cause) }
162
+ }
163
+ if (!['ok', 'absent'].includes(current?.kind)) return { status: 'indeterminate', retained: [...attempt.ownedRefs], reason: `registry-${current?.kind ?? 'unknown'}` }
164
+ const used = usedRefs(current)
165
+ const unset = []
166
+ const retained = []
167
+ const failures = []
168
+ for (const reference of attempt.ownedRefs) {
169
+ if (used.has(reference)) { retained.push(reference); continue }
170
+ try {
171
+ await credentials.unset(reference)
172
+ unset.push(reference)
173
+ } catch (cause) {
174
+ retained.push(reference)
175
+ failures.push({ ref: reference, message: String(cause?.message ?? cause).slice(0, 512) })
176
+ }
177
+ }
178
+ if (failures.length) return { status: 'cleanup-failed', unset, retained, failures }
179
+ return { status: 'cleaned', unset, retained }
180
+ }