weir-harness 1.2.3 → 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/cordis.patch.yml +7 -18
- 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-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Secret-free, bounded MCP diagnostics. Raw Error objects, stacks, URLs with
|
|
2
|
+
// credentials, and arbitrary cause chains never cross the operation boundary.
|
|
3
|
+
export const MCP_DIAGNOSTIC_LIMIT = 8192
|
|
4
|
+
const CAUSE_LIMIT = 6
|
|
5
|
+
|
|
6
|
+
const MESSAGE = value => value instanceof Error ? value.message : String(value ?? 'unknown error')
|
|
7
|
+
|
|
8
|
+
/** Known-secret metadata is internal, inherited through wrapped causes, and never enumerated on the wire. */
|
|
9
|
+
function errorSecrets(error, initial = []) {
|
|
10
|
+
const secrets = [...initial]
|
|
11
|
+
let current = error
|
|
12
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
13
|
+
if (Array.isArray(current?.secrets)) secrets.push(...current.secrets)
|
|
14
|
+
current = current?.cause
|
|
15
|
+
}
|
|
16
|
+
return secrets
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Replace known secrets, then reduce sensitive URL tails to scheme/host/path. */
|
|
20
|
+
function redactKnownAndUrls(text, secrets) {
|
|
21
|
+
const known = [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]
|
|
22
|
+
.sort((a, b) => b.length - a.length)
|
|
23
|
+
for (const secret of known) text = text.split(secret).join('[redacted]')
|
|
24
|
+
// Any sensitive URL component makes the whole userinfo/query/fragment tail
|
|
25
|
+
// sensitive. Preserve only scheme/host/path for actionable diagnostics.
|
|
26
|
+
return text.replace(/\b(?:https?|wss?):\/\/[^\s<>"']+/gi, candidate => {
|
|
27
|
+
const trailing = candidate.match(/[.,;:!?)]*$/)?.[0] ?? ''
|
|
28
|
+
const raw = trailing ? candidate.slice(0, -trailing.length) : candidate
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(raw)
|
|
31
|
+
if (!url.username && !url.password && !url.search && !url.hash) return candidate
|
|
32
|
+
return `${url.protocol}//${url.host}${url.pathname}[redacted-url]${trailing}`
|
|
33
|
+
} catch {
|
|
34
|
+
return '[redacted-url]'
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Free text: replace every known secret before applying pattern-based redaction. */
|
|
40
|
+
export function sanitizeMcpText(value, secrets = []) {
|
|
41
|
+
let text = String(value ?? '')
|
|
42
|
+
const known = [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]
|
|
43
|
+
.sort((a, b) => b.length - a.length)
|
|
44
|
+
for (const secret of known) text = text.split(secret).join('[redacted]')
|
|
45
|
+
// Authorization lines and common secret-bearing key/value spellings.
|
|
46
|
+
text = text.replace(/\b(?:proxy-)?authorization\s*:\s*[^\r\n,;}]+/gi, match => `${match.split(':')[0]}: [redacted]`)
|
|
47
|
+
text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
|
48
|
+
text = text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted-jwt]')
|
|
49
|
+
text = text.replace(/\b(?:api[-_]?key|access[-_]?token|refresh[-_]?token|password|passwd|secret|client[-_]?secret)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, (_match, key, separator) => `${key}${separator}[redacted]`)
|
|
50
|
+
// Shell/environment assignments. Values may be quoted; the key remains useful.
|
|
51
|
+
text = text.replace(/\b[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)/g, assignment => `${assignment.slice(0, assignment.indexOf('='))}=[redacted]`)
|
|
52
|
+
return redactKnownAndUrls(text, [])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Structured MCP configuration fields (argv, command, cwd, label, refs) are
|
|
57
|
+
* exact values the user must be able to read, edit and approve. Secret-bearing
|
|
58
|
+
* positions are already replaced structurally (secretRefs-indexed args, env/
|
|
59
|
+
* header values, sensitive URLs); here only known secret values and sensitive
|
|
60
|
+
* URL tails are removed. The generic KEY=VALUE patterns are for free text only:
|
|
61
|
+
* applying them to argv turned `--port=8080` into `--port=[redacted]`.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeMcpConfigText(value, secrets = []) {
|
|
64
|
+
return redactKnownAndUrls(String(value ?? ''), secrets)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Config projections produced by redactMcpEntry. Membership is process-local and
|
|
68
|
+
// lost on clone/serialization, which only falls back to the stricter free-text
|
|
69
|
+
// sanitizer — never to less redaction.
|
|
70
|
+
const CONFIG_PROJECTIONS = new WeakSet()
|
|
71
|
+
export function markMcpConfigProjection(value) {
|
|
72
|
+
if (value && typeof value === 'object') CONFIG_PROJECTIONS.add(value)
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
function sanitizeConfigProjection(value, secrets) {
|
|
76
|
+
if (typeof value === 'string') return sanitizeMcpConfigText(value, secrets)
|
|
77
|
+
if (Array.isArray(value)) return value.map(item => sanitizeConfigProjection(item, secrets))
|
|
78
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeConfigProjection(item, secrets)]))
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sanitizeMcpPayload(value, secrets = []) {
|
|
83
|
+
if (typeof value === 'string') return sanitizeMcpText(value, secrets)
|
|
84
|
+
if (Array.isArray(value)) return value.map(item => sanitizeMcpPayload(item, secrets))
|
|
85
|
+
if (value && typeof value === 'object') {
|
|
86
|
+
if (CONFIG_PROJECTIONS.has(value)) return markMcpConfigProjection(sanitizeConfigProjection(value, secrets))
|
|
87
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeMcpPayload(item, secrets)]))
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const RULES = [
|
|
93
|
+
['credential-missing', /credential[- ]missing|credential.*(?:not configured|empty|missing)|missing.*credential/i, 'Configure a non-empty credential reference, then retry.'],
|
|
94
|
+
['name-conflict', /serverName.*(?:already in use|conflict)|name[- ]conflict|namespace.*conflict/i, 'Choose a unique public server name or remove the conflicting registration.'],
|
|
95
|
+
['executable-not-found', /\bENOENT\b|executable.*(?:not found|unavailable)|command.*not found|cannot find.*(?:command|executable)/i, 'Check the command and its GUI PATH resolution, then retry.'],
|
|
96
|
+
['permission-denied', /\bEACCES\b|\bEPERM\b|permission denied|access denied/i, 'Check executable and credential/file permissions, then retry.'],
|
|
97
|
+
['connection-refused', /ECONNREFUSED|connection refused/i, 'Check that the server is listening and the endpoint is correct, then retry.'],
|
|
98
|
+
['http-unauthorized', /\bHTTP\s*40[13]\b|\b40[13]\b|unauthorized|forbidden/i, 'Check the HTTP credential reference and server authorization policy.'],
|
|
99
|
+
['timeout', /timeout|timed out|deadline exceeded/i, 'Inspect server startup and connectivity, then retry with a healthy server.'],
|
|
100
|
+
['invalid-config', /invalid config|validation|unsupported transport|unknown (?:option|field)|initialDelayMs|maxDelayMs/i, 'Correct the marked configuration field and preview again.'],
|
|
101
|
+
['cleanup-unknown', /cleanup-unknown|closure could not be confirmed|unconfirmed (?:transport )?clos/i, 'Inspect residue before retrying; an immediate same-name replacement is blocked.'],
|
|
102
|
+
['cleanup-failed', /cleanup-failed|dispose(?:d|al)?(?: rejection| failed)|remove rejection/i, 'Inspect the cleanup diagnostic and recover the remaining Loader entry before retrying.'],
|
|
103
|
+
['unsupported', /unsupported|ephemeral.*unavailable/i, 'Use a secret-free configuration or a Host with the required ephemeral Loader support.'],
|
|
104
|
+
['owner-changed', /owner-changed|preset.*changed|ownership.*changed/i, 'Return to the original session/preset and preview again.'],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
export function classifyMcpError(error, secrets = []) {
|
|
108
|
+
secrets = errorSecrets(error, secrets)
|
|
109
|
+
const evidence = []
|
|
110
|
+
let current = error
|
|
111
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
112
|
+
evidence.push(`${current?.code ?? ''} ${MESSAGE(current)}`)
|
|
113
|
+
current = current?.cause
|
|
114
|
+
}
|
|
115
|
+
const text = sanitizeMcpText(evidence.join('\n'), secrets)
|
|
116
|
+
const match = RULES.find(([, pattern]) => pattern.test(text))
|
|
117
|
+
if (!match) return { code: 'unknown-error', hint: 'Inspect the sanitized diagnostic and retry after correcting the cause.' }
|
|
118
|
+
return { code: match[0], hint: match[2] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function boundedMessage(text, max = 4096) {
|
|
122
|
+
const clean = String(text)
|
|
123
|
+
return Buffer.byteLength(clean) <= max ? clean : Buffer.from(clean).subarray(0, max - 1).toString().replace(/\uFFFD+$/g, '') + '…'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Convert an Error/cause chain into one stable secret-free diagnostic. Both
|
|
128
|
+
* individual strings and the complete JSON projection are bounded to 8 KiB.
|
|
129
|
+
*/
|
|
130
|
+
export function mcpDiagnostic(error, options = {}) {
|
|
131
|
+
const secrets = errorSecrets(error, options.secrets ?? [])
|
|
132
|
+
const classified = options.code ? { code: options.code, hint: options.hint } : classifyMcpError(error, secrets)
|
|
133
|
+
const causes = []
|
|
134
|
+
let current = error
|
|
135
|
+
for (let depth = 0; current && depth < CAUSE_LIMIT; depth += 1) {
|
|
136
|
+
causes.push(boundedMessage(sanitizeMcpText(MESSAGE(current), secrets), 1024))
|
|
137
|
+
current = current?.cause
|
|
138
|
+
}
|
|
139
|
+
const first = causes[0] ?? boundedMessage(sanitizeMcpText(MESSAGE(error), secrets), 1024)
|
|
140
|
+
let diagnostic = {
|
|
141
|
+
code: classified.code ?? 'unknown-error',
|
|
142
|
+
message: boundedMessage(sanitizeMcpText(options.message ?? first, secrets)),
|
|
143
|
+
hint: boundedMessage(sanitizeMcpText(options.hint ?? classified.hint ?? 'Retry after correcting the cause.'), 1024),
|
|
144
|
+
recovery: { action: options.action ?? 'retry' },
|
|
145
|
+
causes,
|
|
146
|
+
}
|
|
147
|
+
while (Buffer.byteLength(JSON.stringify(diagnostic)) > MCP_DIAGNOSTIC_LIMIT && diagnostic.causes.length > 1) {
|
|
148
|
+
diagnostic.causes.pop()
|
|
149
|
+
}
|
|
150
|
+
if (Buffer.byteLength(JSON.stringify(diagnostic)) > MCP_DIAGNOSTIC_LIMIT) {
|
|
151
|
+
diagnostic.message = boundedMessage(diagnostic.message, 2048)
|
|
152
|
+
diagnostic.hint = boundedMessage(diagnostic.hint, 512)
|
|
153
|
+
}
|
|
154
|
+
return diagnostic
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class McpLifecycleError extends Error {
|
|
158
|
+
constructor(code, error, options = {}) {
|
|
159
|
+
const secrets = errorSecrets(error, options.secrets ?? [])
|
|
160
|
+
const diagnostic = mcpDiagnostic(error, { ...options, secrets, code, hint: options.hint })
|
|
161
|
+
super(diagnostic.message, options.cause === undefined && error instanceof Error ? { cause: error } : {})
|
|
162
|
+
this.name = 'McpLifecycleError'
|
|
163
|
+
this.code = code
|
|
164
|
+
this.status = options.status ?? code
|
|
165
|
+
this.diagnostic = diagnostic
|
|
166
|
+
Object.defineProperty(this, 'secrets', { value: Object.freeze([...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length > 0))]), enumerable: false })
|
|
167
|
+
this.cleanup = options.cleanup === undefined ? undefined : sanitizeMcpPayload(options.cleanup, secrets)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
15
15
|
/** @type {Map<string, { count: number, calls: Map<number, string>, closed: boolean }>} */
|
|
16
16
|
const flights = new Map()
|
|
17
|
+
const closedServers = new Set()
|
|
17
18
|
let sequence = 0
|
|
18
19
|
|
|
19
20
|
const keyOf = (agentId, server) => `${agentId ?? 'unknown'}${server}`
|
|
20
21
|
const stateOf = (agentId, server) => {
|
|
21
22
|
const key = keyOf(agentId, server)
|
|
22
|
-
if (!flights.has(key)) flights.set(key, { count: 0, calls: new Map(), closed: false })
|
|
23
|
+
if (!flights.has(key)) flights.set(key, { server, count: 0, calls: new Map(), closed: false })
|
|
23
24
|
return flights.get(key)
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -30,7 +31,7 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
30
31
|
*/
|
|
31
32
|
function enter(agentId, server, label = 'call') {
|
|
32
33
|
const state = stateOf(agentId, server)
|
|
33
|
-
if (state.closed) return null
|
|
34
|
+
if (state.closed || closedServers.has(server)) return null
|
|
34
35
|
sequence += 1
|
|
35
36
|
state.count += 1
|
|
36
37
|
state.calls.set(sequence, label)
|
|
@@ -68,7 +69,7 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function isClosed(agentId, server) {
|
|
71
|
-
return stateOf(agentId, server).closed
|
|
72
|
+
return stateOf(agentId, server).closed || closedServers.has(server)
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** The Apply-time snapshot the response carries (sent at response time). */
|
|
@@ -77,7 +78,20 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
|
|
|
77
78
|
return { server, state: state.count === 0 ? 'settled' : 'draining', inFlight: state.count }
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
function closeAll(server) { closedServers.add(server) }
|
|
82
|
+
function reopenAll(server) { closedServers.delete(server) }
|
|
83
|
+
async function drainAll(server) {
|
|
84
|
+
const states = [...flights.values()].filter(state => state.server === server)
|
|
85
|
+
const started = now()
|
|
86
|
+
const totals = () => ({ count: states.reduce((sum, state) => sum + state.count, 0), calls: states.flatMap(state => [...state.calls.values()]) })
|
|
87
|
+
while (totals().count > 0 && now() - started < timeoutMs) await sleep(Math.min(50, timeoutMs))
|
|
88
|
+
const current = totals()
|
|
89
|
+
return current.count === 0
|
|
90
|
+
? { drained: true, waitedMs: now() - started }
|
|
91
|
+
: { drained: false, inFlight: current.count, calls: current.calls, waitedMs: now() - started }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { enter, exit, close, reopen, isClosed, drain, snapshot, closeAll, reopenAll, drainAll }
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
/**
|
|
@@ -32,8 +32,8 @@ const message = error => (error instanceof Error ? error.message : String(error)
|
|
|
32
32
|
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
|
|
33
33
|
* onError?: (reason: string, cause?: unknown) => void }} config
|
|
34
34
|
*/
|
|
35
|
-
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
|
|
36
|
-
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain })
|
|
35
|
+
export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {}, assertPermit }) {
|
|
36
|
+
const wrapper = createMcpToolWrapper({ identity, generation, admit, drain, assertPermit })
|
|
37
37
|
|
|
38
38
|
const unknown = (service, method) => {
|
|
39
39
|
const reason = `facade-unknown-method:${service}.${method}`
|
|
@@ -91,7 +91,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
91
91
|
const gated = typeof request === 'function'
|
|
92
92
|
? { ...provider, async request(args, exec) {
|
|
93
93
|
const admitted = await admit(exec?.agent)
|
|
94
|
-
if (!admitted) {
|
|
94
|
+
if (!admitted || (assertPermit && !assertPermit(admitted))) {
|
|
95
95
|
const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
|
|
96
96
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
97
97
|
}
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// 1. the registry's configured identity + current generation (8.1) — a
|
|
5
|
-
// reconnect mounts a NEW generation and every old-generation handle is
|
|
6
|
-
// rejected; a same-named DIFFERENT identity inherits nothing (8.5);
|
|
7
|
-
// 2. the session lifecycle snapshot: BLOCKED sessions refuse every managed
|
|
8
|
-
// call (6.4); a dispose/reload window without an initialized snapshot
|
|
9
|
-
// refuses too (8.5);
|
|
10
|
-
// 3. the session's accepted managed-MCP set (the selection record's
|
|
11
|
-
// mcpServers): a server not in the set is not enabled for that agent.
|
|
12
|
-
// Connections stay shared at the host plane — a session losing a server
|
|
13
|
-
// never disposes anything (8.5).
|
|
1
|
+
// Managed MCP admission combines runtime registration generation/incarnation,
|
|
2
|
+
// the accepted lifecycle selection, and (when mounted) the per-identity runtime
|
|
3
|
+
// phase. Legacy compatibility exists only when BOTH sides lack incarnations.
|
|
14
4
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
export function mcpIncarnationMatches(snapshot, identity, entry) {
|
|
6
|
+
if (!entry) return false
|
|
7
|
+
const mapping = snapshot?.mcpIncarnations
|
|
8
|
+
if (mapping && typeof mapping === 'object' && !Array.isArray(mapping)) {
|
|
9
|
+
if (!Object.hasOwn(mapping, identity)) return false
|
|
10
|
+
return mapping[identity] === (entry.incarnation ?? null)
|
|
11
|
+
}
|
|
12
|
+
// A legacy selection without a mapping remains valid only for a legacy entry.
|
|
13
|
+
return entry.incarnation == null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function acceptedSnapshot(lifecycle, agent, { parentFallback = false } = {}) {
|
|
17
|
+
const sessionId = agent?.session?.id
|
|
18
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
19
|
+
let snapshot = lifecycle.snapshotFor(sessionId)
|
|
20
|
+
if (snapshot == null && parentFallback) {
|
|
21
|
+
const parentId = agent?.session?.header?.parentSession
|
|
22
|
+
if (typeof parentId === 'string' && parentId.length > 0) snapshot = lifecycle.snapshotFor(parentId)
|
|
23
|
+
}
|
|
24
|
+
return snapshot?.state === 'ready' ? snapshot : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createMcpGate({ registry, lifecycle, sessionMcpServers, registrationFor, runtime }) {
|
|
21
28
|
const acceptedOf = sessionId => {
|
|
22
29
|
if (typeof sessionMcpServers === 'function') return sessionMcpServers(sessionId)
|
|
23
30
|
const snapshot = lifecycle.snapshotFor(sessionId)
|
|
@@ -25,47 +32,39 @@ export function createMcpGate({ registry, lifecycle, sessionMcpServers }) {
|
|
|
25
32
|
return snapshot.mcpServers
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
* The facade-side verdict for one call.
|
|
30
|
-
* @param {unknown} agent @param {string} identity @param {number} generation
|
|
31
|
-
* @returns {Promise<boolean>}
|
|
32
|
-
*/
|
|
33
|
-
async function admit(agent, identity, generation) {
|
|
35
|
+
async function permit(agent, identity, generation) {
|
|
34
36
|
const sessionId = agent?.session?.id
|
|
35
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (snapshot?.state !== 'ready') return false
|
|
37
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
|
|
38
|
+
if (runtime?.enabledFor && runtime.enabledFor(identity) === false) return null
|
|
39
|
+
const snapshot = acceptedSnapshot(lifecycle, agent)
|
|
40
|
+
if (!snapshot) return null
|
|
40
41
|
const accepted = acceptedOf(sessionId)
|
|
41
|
-
if (!Array.isArray(accepted) || !accepted.includes(identity)) return
|
|
42
|
+
if (!Array.isArray(accepted) || !accepted.includes(identity)) return null
|
|
43
|
+
if (runtime?.beforeRead && runtime.beforeRead(identity) === false) return null
|
|
42
44
|
const current = await registry.read()
|
|
43
|
-
if (current.kind !== 'ok') return
|
|
45
|
+
if (current.kind !== 'ok') return null
|
|
44
46
|
const entry = current.servers[identity]
|
|
45
|
-
|
|
47
|
+
if (!entry || entry.generation !== generation || !mcpIncarnationMatches(snapshot, identity, entry)) return null
|
|
48
|
+
if (runtime?.afterRead && runtime.afterRead(identity) === false) return null
|
|
49
|
+
if (runtime?.permit) return runtime.permit(identity, { generation, incarnation: entry.incarnation ?? null })
|
|
50
|
+
return { identity, generation, incarnation: entry.incarnation ?? null, operationId: null }
|
|
51
|
+
}
|
|
52
|
+
async function admit(agent, identity, generation) {
|
|
53
|
+
return (await permit(agent, identity, generation)) !== null
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
/**
|
|
49
|
-
* Instruction-section filtering (8.4): is the server in the session's
|
|
50
|
-
* accepted set? At a subagent's creation the inherited capture is what the
|
|
51
|
-
* child gets — the parent's accepted set VERBATIM at that moment — so when
|
|
52
|
-
* the child's own snapshot is not visible yet (the creation-time
|
|
53
|
-
* schema-hiding listener races the capture listener), the parent's
|
|
54
|
-
* snapshot stands in. Call admission (admit) never uses this fallback: a
|
|
55
|
-
* snapshot-less child is refused, never granted the parent's set.
|
|
56
|
-
*/
|
|
57
56
|
function enabledFor(agent, identity) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
if (runtime?.enabledFor && runtime.enabledFor(identity) === false) return false
|
|
58
|
+
const snapshot = acceptedSnapshot(lifecycle, agent, { parentFallback: true })
|
|
59
|
+
if (!snapshot || !Array.isArray(snapshot.mcpServers) || !snapshot.mcpServers.includes(identity)) return false
|
|
60
|
+
if (typeof registrationFor !== 'function') {
|
|
61
|
+
// Pure legacy tests without a runtime registry still get mapping-shape
|
|
62
|
+
// enforcement; production always supplies registrationFor below.
|
|
63
|
+
const mapping = snapshot.mcpIncarnations
|
|
64
|
+
return !(mapping && typeof mapping === 'object' && !Array.isArray(mapping) && !Object.hasOwn(mapping, identity))
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
const accepted = snapshot.mcpServers
|
|
67
|
-
return Array.isArray(accepted) && accepted.includes(identity)
|
|
66
|
+
return mcpIncarnationMatches(snapshot, identity, registrationFor(identity))
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
return { admit, enabledFor }
|
|
69
|
+
return { admit, permit, enabledFor, assertPermit: value => runtime?.assertPermit ? runtime.assertPermit(value) : Boolean(value) }
|
|
71
70
|
}
|