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.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. 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
- return { enter, exit, close, reopen, isClosed, drain, snapshot }
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
- // Task 8.2/8.5 of the session-capability-manager change: the admission gate
2
- // service the facades consume. The verdict for "is this managed server
3
- // usable by this agent" combines, in order:
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
- * @param {{ registry: { read(): Promise<any> },
17
- * lifecycle: { snapshotFor(sessionId: string): any },
18
- * sessionMcpServers?: (sessionId: string) => string[] | null }} options
19
- */
20
- export function createMcpGate({ registry, lifecycle, sessionMcpServers }) {
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 false
36
- const snapshot = lifecycle.snapshotFor(sessionId)
37
- // BLOCKED (6.4) or no initialized snapshot (dispose/reload window, 8.5):
38
- // every managed call is refused, never an unrestricted pass.
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 false
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 false
45
+ if (current.kind !== 'ok') return null
44
46
  const entry = current.servers[identity]
45
- return Boolean(entry && entry.generation === generation)
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
- const sessionId = agent?.session?.id
59
- if (typeof sessionId !== 'string' || sessionId.length === 0) return false
60
- let snapshot = lifecycle.snapshotFor(sessionId)
61
- if (snapshot?.state !== 'ready') {
62
- const parentId = agent?.session?.header?.parentSession
63
- if (typeof parentId === 'string' && parentId.length > 0) snapshot = lifecycle.snapshotFor(parentId)
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
- if (snapshot?.state !== 'ready') return false
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
  }