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.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- 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
|
+
}
|