weir-harness 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +40 -9
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +170 -57
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -0,0 +1,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
  /**
@@ -30,10 +30,11 @@ const message = error => (error instanceof Error ? error.message : String(error)
30
30
  * admit: (agent: unknown) => Promise<boolean>,
31
31
  * isEnabledFor: (agent: unknown) => boolean,
32
32
  * drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
33
+ * onRegister?: (name: string, captureRollback: (rollback: () => void) => void) => void, onUnregister?: (name: string) => void,
33
34
  * onError?: (reason: string, cause?: unknown) => void }} config
34
35
  */
35
- export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
36
- const wrapper = createMcpToolWrapper({ identity, generation, admit, drain })
36
+ export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onUnregister, onError = () => {}, assertPermit }) {
37
+ const wrapper = createMcpToolWrapper({ identity, generation, admit, drain, assertPermit })
37
38
 
38
39
  const unknown = (service, method) => {
39
40
  const reason = `facade-unknown-method:${service}.${method}`
@@ -43,14 +44,44 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
43
44
 
44
45
  const tools = {
45
46
  register(definition) {
46
- // Bind the public name to this configured identity at registration
47
- // time, then wrap the definition with the last-moment admission check.
47
+ // Host registration happens first, then the generation-owned fact and
48
+ // every live agent converge in this same synchronous segment.
48
49
  const wrapped = wrapper.wrap(definition)
49
50
  const registration = /** @type {{ register(d: unknown): unknown }} */ (host.tools).register(wrapped)
50
- // Report the binding back to the manager (creation-time schema hiding
51
- // and the managed listing read this map).
52
- if (typeof wrapped?.name === 'string') onRegister?.(wrapped.name)
53
- return registration
51
+ if (typeof wrapped?.name !== 'string') return registration
52
+ const name = wrapped.name
53
+ const disposeHost = typeof registration === 'function' ? registration : () => {}
54
+ let rollbackRegistration = null
55
+ try {
56
+ onRegister?.(name, rollback => { rollbackRegistration = rollback })
57
+ } catch (cause) {
58
+ // Host-first rollback is fail-closed: owner facts and partial denies
59
+ // stay in place unless Host removal is confirmed.
60
+ try { disposeHost() } catch (rollbackError) {
61
+ onError('facade-host-registration-rollback-failed', rollbackError)
62
+ throw cause
63
+ }
64
+ try {
65
+ if (typeof rollbackRegistration === 'function') rollbackRegistration()
66
+ else onUnregister?.(name) // legacy registration callbacks
67
+ } catch (rollbackError) {
68
+ onError('facade-owner-registration-rollback-failed', rollbackError)
69
+ }
70
+ throw cause
71
+ }
72
+ let hostRemoved = false
73
+ let completed = false
74
+ return () => {
75
+ if (completed) return
76
+ if (!hostRemoved) {
77
+ // Host-first is fail-closed: if removal throws, the manager fact and
78
+ // deny stay in place. A later retry does not double-dispose the Host.
79
+ disposeHost()
80
+ hostRemoved = true
81
+ }
82
+ onUnregister?.(name)
83
+ completed = true
84
+ }
54
85
  },
55
86
  get(...args) { return /** @type {{ get(...a: unknown[]): unknown }} */ (host.tools).get(...args) },
56
87
  list(...args) { return /** @type {{ list(...a: unknown[]): unknown }} */ (host.tools).list(...args) },
@@ -91,7 +122,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
91
122
  const gated = typeof request === 'function'
92
123
  ? { ...provider, async request(args, exec) {
93
124
  const admitted = await admit(exec?.agent)
94
- if (!admitted) {
125
+ if (!admitted || (assertPermit && !assertPermit(admitted))) {
95
126
  const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
96
127
  throw Object.assign(error, { code: 'mcp-admission-refused' })
97
128
  }
@@ -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
  }