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
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
// never yields: no promise chains, no async helpers, only blocking
|
|
10
10
|
// synchronous file I/O where durability is required.
|
|
11
11
|
//
|
|
12
|
-
// 6.1 — in-memory snapshot:
|
|
13
|
-
// plugin apply (lifecycle-preload.js) and after every Apply
|
|
14
|
-
// apply-engine publishSnapshot hook, invoked inside the same
|
|
15
|
-
// segment as the internal snapshot swap). The listener reads the
|
|
16
|
-
// snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
|
|
17
|
-
// falls back to
|
|
18
|
-
//
|
|
12
|
+
// 6.1 — in-memory snapshot: the session's EFFECTIVE capability set is loaded
|
|
13
|
+
// into memory at plugin apply (lifecycle-preload.js) and after every Apply
|
|
14
|
+
// acceptance (the apply-engine publishSnapshot hook, invoked inside the same
|
|
15
|
+
// non-async segment as the internal snapshot swap). The listener reads the
|
|
16
|
+
// memory snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
|
|
17
|
+
// falls back to blocking synchronous reads of the session records — the
|
|
18
|
+
// accepted selection FIRST, then the durable inherited snapshot when no
|
|
19
|
+
// accepted record exists (a fork of a fork, a resumed incarnation), never the
|
|
20
|
+
// host storageDomain (a promise interface, unusable here, CITED).
|
|
19
21
|
//
|
|
20
22
|
// 6.2 — synchronous durable capture: a subagent's inherited snapshot is
|
|
21
23
|
// written inside the listener through the capability store's SYNC commit path
|
|
@@ -39,19 +41,29 @@
|
|
|
39
41
|
// decoded does NOT throw — the session is created/resumed as usual and
|
|
40
42
|
// the memory snapshot becomes BLOCKED (empty Skill view, all managed MCP
|
|
41
43
|
// calls refused, recovery entry visible). Never a full-discovery
|
|
42
|
-
// fallback; the original file is never rewritten.
|
|
44
|
+
// fallback; the original file is never rewritten. An inherited record is
|
|
45
|
+
// consulted ONLY when no accepted record exists, and an existing record
|
|
46
|
+
// that cannot be decoded blocks just as hard — a corrupt higher-priority
|
|
47
|
+
// record is never skipped in favour of a lower-priority one or of the
|
|
48
|
+
// initial baseline.
|
|
43
49
|
// - resume/fork incarnation (depth 0 yet a parentSession header): the named
|
|
44
|
-
// parent's
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
50
|
+
// parent's EFFECTIVE snapshot (accepted, inherited, or the already-resolved
|
|
51
|
+
// initial one) is captured best-effort under the SAME root rule — a failed
|
|
52
|
+
// capture never throws and never rejects the session; the session runs
|
|
53
|
+
// BLOCKED like an unreadable root record, with the manager naming an
|
|
54
|
+
// explicit Apply as the recovery gesture. A parent with NO effective
|
|
55
|
+
// snapshot at all (proven absent on supported storage) is not an empty
|
|
56
|
+
// set: the capture is skipped and the fork resolves the ordinary root
|
|
57
|
+
// initialization policy for itself.
|
|
58
|
+
// - subagent (one-shot / background / supervised member / escalation; a
|
|
59
|
+
// depth-0 fork child is handled by the incarnation rule above): a parent
|
|
60
|
+
// snapshot that cannot be read, or a capture that cannot be written, makes
|
|
61
|
+
// the listener THROW. The host dispatches agent/created serially and a
|
|
62
|
+
// listener failure rejects the creation, so throwing is the INTENTIONAL
|
|
63
|
+
// bail that refuses the subagent while the parent session continues and
|
|
64
|
+
// learns the reason through the delegation result. Never an unrestricted
|
|
65
|
+
// pass, and never the root-initialization fallback (that is a depth-0 fork
|
|
66
|
+
// affordance only).
|
|
55
67
|
import { isSegment } from './store/paths.js'
|
|
56
68
|
import { openCapabilityStore } from './store/store.js'
|
|
57
69
|
import { skillIdentityKey } from './skill-identity.js'
|
|
@@ -60,17 +72,28 @@ import { skillIdentityKey } from './skill-identity.js'
|
|
|
60
72
|
export const INHERITED_ORIGIN = 'inherited'
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
|
-
* @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[],
|
|
75
|
+
* @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[], mcpIncarnations?: Record<string, string|null>,
|
|
64
76
|
* captured: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} ReadySnapshot
|
|
65
77
|
* @typedef {{ state: 'blocked', sessionId: string, reason: 'corrupt'|'unknown-schema'|'torn'|'unreadable' }} BlockedSnapshot
|
|
66
78
|
* @typedef {ReadySnapshot | BlockedSnapshot} LifecycleSnapshot
|
|
67
|
-
* @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[],
|
|
79
|
+
* @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[], mcpIncarnations?: Record<string, string|null>,
|
|
68
80
|
* captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} SnapshotInput
|
|
69
81
|
*/
|
|
70
82
|
|
|
71
83
|
/** @param {unknown} error */
|
|
72
84
|
const message = error => (error instanceof Error ? error.message : String(error))
|
|
73
85
|
|
|
86
|
+
export function normalizeMcpIncarnations(value) {
|
|
87
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
88
|
+
return Object.fromEntries(Object.entries(value).filter(([, incarnation]) => incarnation === null || typeof incarnation === 'string'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function mcpIncarnationsFor(servers, mapping) {
|
|
92
|
+
const normalized = normalizeMcpIncarnations(mapping)
|
|
93
|
+
if (!normalized) return undefined
|
|
94
|
+
return Object.fromEntries(servers.filter(identity => Object.hasOwn(normalized, identity)).map(identity => [identity, normalized[identity]]))
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* In-memory lifecycle snapshots plus the synchronous read/capture paths.
|
|
76
99
|
* All durable I/O delegates to the capability store's sync path: the same
|
|
@@ -118,6 +141,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
118
141
|
revision: next.revision,
|
|
119
142
|
skills: Object.freeze(structuredClone(Array.isArray(next.skills) ? next.skills : [])),
|
|
120
143
|
mcpServers: Object.freeze((Array.isArray(next.mcpServers) ? next.mcpServers : []).slice()),
|
|
144
|
+
...(normalizeMcpIncarnations(next.mcpIncarnations) ? { mcpIncarnations: Object.freeze(normalizeMcpIncarnations(next.mcpIncarnations)) } : {}),
|
|
121
145
|
captured: next.captured ? Object.freeze({ ...next.captured }) : null,
|
|
122
146
|
})
|
|
123
147
|
snapshots.set(sessionId, snapshot)
|
|
@@ -146,11 +170,14 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
146
170
|
|
|
147
171
|
/**
|
|
148
172
|
* THE synchronous read path (6.1): memory hit returns immediately; a
|
|
149
|
-
* miss performs
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
173
|
+
* miss performs blocking synchronous reads of the durable records — the
|
|
174
|
+
* store's sync read, never a promise, never a yield. The effective snapshot
|
|
175
|
+
* is the accepted selection when one exists, otherwise the durable inherited
|
|
176
|
+
* snapshot (a fork whose source only ever resolved an initial set, or a fork
|
|
177
|
+
* of a fork). An absent pair returns null (no effective snapshot; the fork's
|
|
178
|
+
* own root initialization is layered above this mechanism). Any record that
|
|
179
|
+
* exists and does not decode becomes BLOCKED — a corrupt higher-priority
|
|
180
|
+
* record never falls through to a lower-priority one.
|
|
154
181
|
* @param {string} sessionId
|
|
155
182
|
* @returns {LifecycleSnapshot | null}
|
|
156
183
|
*/
|
|
@@ -166,19 +193,62 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
166
193
|
// closed BLOCKED (6.4), never a guessed full authorization.
|
|
167
194
|
return markBlocked(sessionId, 'unreadable')
|
|
168
195
|
}
|
|
169
|
-
// No accepted record yet — a legitimate cold state, not a failure.
|
|
170
|
-
if (record.kind === 'absent') return null
|
|
171
196
|
if (record.kind === 'ok') {
|
|
172
197
|
const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
|
|
173
198
|
return publish(sessionId, {
|
|
174
199
|
revision: record.revision,
|
|
175
200
|
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
176
201
|
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
|
|
202
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
177
203
|
})
|
|
178
204
|
}
|
|
179
205
|
// corrupt / unknown-schema / torn / unsupported: fail closed BLOCKED
|
|
180
206
|
// (6.4 root rule). The original file is never rewritten from this path.
|
|
181
|
-
return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
|
|
207
|
+
if (record.kind !== 'absent') return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
|
|
208
|
+
// No accepted record — a legitimate cold state. The EFFECTIVE set may still
|
|
209
|
+
// be the durable inherited snapshot this session was created with.
|
|
210
|
+
return snapshotFromInherited(sessionId)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The inherited fallback of {@link snapshotFor}: no accepted record exists,
|
|
215
|
+
* so the effective set is whatever durable inheritance the session was given
|
|
216
|
+
* at creation. The snapshot keeps the inherited revision and restores the
|
|
217
|
+
* parent/captured metadata — it is NEVER reported as an accepted revision
|
|
218
|
+
* (the receipt's CAS token stays accepted-only, 0 when absent). A record
|
|
219
|
+
* that exists but does not decode blocks exactly like a corrupt selection
|
|
220
|
+
* record: no fall-through to the initial baseline.
|
|
221
|
+
* @param {string} sessionId
|
|
222
|
+
* @returns {LifecycleSnapshot | null}
|
|
223
|
+
*/
|
|
224
|
+
function snapshotFromInherited(sessionId) {
|
|
225
|
+
let inherited
|
|
226
|
+
try {
|
|
227
|
+
inherited = store.readSync({ kind: 'inherited', sessionId })
|
|
228
|
+
} catch {
|
|
229
|
+
return markBlocked(sessionId, 'unreadable')
|
|
230
|
+
}
|
|
231
|
+
if (inherited.kind !== 'ok') {
|
|
232
|
+
if (inherited.kind === 'absent') return null
|
|
233
|
+
return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (inherited.kind))
|
|
234
|
+
}
|
|
235
|
+
const payload = /** @type {Record<string, unknown>} */ (inherited.payload ?? {})
|
|
236
|
+
const parent = /** @type {Record<string, unknown> | null} */ (
|
|
237
|
+
payload.parent && typeof payload.parent === 'object' ? payload.parent : null)
|
|
238
|
+
const capturedAt = typeof payload.capturedAt === 'number' && Number.isFinite(payload.capturedAt) ? payload.capturedAt : 0
|
|
239
|
+
return publish(sessionId, {
|
|
240
|
+
revision: inherited.revision,
|
|
241
|
+
skills: Array.isArray(payload.skills) ? payload.skills : [],
|
|
242
|
+
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
|
|
243
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
244
|
+
captured: parent && typeof parent.sessionId === 'string' && parent.sessionId.length > 0
|
|
245
|
+
? {
|
|
246
|
+
parentSessionId: parent.sessionId,
|
|
247
|
+
parentRevision: Number.isSafeInteger(parent.revision) ? /** @type {number} */ (parent.revision) : null,
|
|
248
|
+
capturedAt,
|
|
249
|
+
}
|
|
250
|
+
: null,
|
|
251
|
+
})
|
|
182
252
|
}
|
|
183
253
|
|
|
184
254
|
/**
|
|
@@ -219,13 +289,15 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
219
289
|
}
|
|
220
290
|
|
|
221
291
|
/**
|
|
222
|
-
* Durably capture a
|
|
292
|
+
* Durably capture a session's inherited snapshot (6.2), synchronously.
|
|
223
293
|
* The inherited sets follow creation-time inheritance (6.3): verbatim
|
|
224
294
|
* parent at creation, child-previous ∩ current-parent on an explicit
|
|
225
295
|
* resume/escalation, narrowed by delegation constraints — never widened.
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
296
|
+
* The parent's EFFECTIVE set is its accepted selection when one exists,
|
|
297
|
+
* otherwise its own inherited snapshot (a fork of a fork). A parent with NO
|
|
298
|
+
* effective snapshot at all is not an empty inheritance: the depth-0 fork
|
|
299
|
+
* caller skips this capture entirely (see `agentCreated`) and lets that fork
|
|
300
|
+
* run the ordinary root initialization instead.
|
|
229
301
|
*
|
|
230
302
|
* NOT ATOMIC with the host's session publication (see the module header):
|
|
231
303
|
* a crash between the two durable acts leaves a child session without a
|
|
@@ -270,9 +342,11 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
270
342
|
: null
|
|
271
343
|
let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
|
|
272
344
|
if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
|
|
345
|
+
const mcpIncarnations = mcpIncarnationsFor(mcpServers, parent?.mcpIncarnations)
|
|
273
346
|
const payload = {
|
|
274
347
|
skills: structuredClone(skills),
|
|
275
348
|
mcpServers,
|
|
349
|
+
...(mcpIncarnations ? { mcpIncarnations } : {}),
|
|
276
350
|
origin: INHERITED_ORIGIN,
|
|
277
351
|
parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
|
|
278
352
|
capturedAt,
|
|
@@ -290,6 +364,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
290
364
|
revision: result.revision,
|
|
291
365
|
skills: payload.skills,
|
|
292
366
|
mcpServers: payload.mcpServers,
|
|
367
|
+
mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
|
|
293
368
|
captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
|
|
294
369
|
})
|
|
295
370
|
}
|
|
@@ -320,7 +395,7 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
320
395
|
// record becomes BLOCKED inside snapshotFor — this path NEVER throws.
|
|
321
396
|
const own = snapshotFor(sessionId)
|
|
322
397
|
// Resume/fork incarnation (D1): depth zero yet a parentSession
|
|
323
|
-
// header. The read side demands an
|
|
398
|
+
// header. The read side demands an effective snapshot for such a
|
|
324
399
|
// session exactly when it carries NO accepted record of its own, so
|
|
325
400
|
// the capture side aligns with that gate: an incarnation WITH its
|
|
326
401
|
// own accepted record keeps it verbatim (an explicit user Apply is
|
|
@@ -331,12 +406,25 @@ export function createLifecycleSnapshots(options = {}) {
|
|
|
331
406
|
// parentSession equal to the session id is no parent (defensive).
|
|
332
407
|
const parentSessionId = session.header?.parentSession
|
|
333
408
|
if (own === null && typeof parentSessionId === 'string' && parentSessionId.length > 0 && parentSessionId !== sessionId) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
// The fork inherits the source's EFFECTIVE set: accepted, else
|
|
410
|
+
// inherited, else the already-resolved initial snapshot. Only a
|
|
411
|
+
// parent snapshot PROVEN absent (null on supported storage) is not a
|
|
412
|
+
// set to copy — an unsupported store proves nothing, so that case
|
|
413
|
+
// keeps the old attempt-and-block shape instead of silently
|
|
414
|
+
// initializing the fork.
|
|
415
|
+
const parent = snapshotFor(parentSessionId)
|
|
416
|
+
const provenAbsent = parent === null && support.supported
|
|
417
|
+
if (!provenAbsent) {
|
|
418
|
+
try {
|
|
419
|
+
captureInherited(sessionId, parentSessionId)
|
|
420
|
+
} catch (captureError) {
|
|
421
|
+
warn(`resume incarnation capture failed for session "${sessionId}": ${message(captureError)}`)
|
|
422
|
+
markBlocked(sessionId, 'unreadable')
|
|
423
|
+
}
|
|
339
424
|
}
|
|
425
|
+
// provenAbsent: no capture and no empty inherited record. The first
|
|
426
|
+
// readSelection then runs the ordinary root initialization for this
|
|
427
|
+
// depth-0 fork and publishes its own revision-0 snapshot.
|
|
340
428
|
}
|
|
341
429
|
return undefined
|
|
342
430
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* admit: (agent: unknown) => Promise<boolean>,
|
|
14
14
|
* drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void } }} config
|
|
15
15
|
*/
|
|
16
|
-
export function createMcpToolWrapper({ identity, generation, admit, drain }) {
|
|
16
|
+
export function createMcpToolWrapper({ identity, generation, admit, drain, assertPermit }) {
|
|
17
17
|
/**
|
|
18
18
|
* Wrap one tool definition with the last-moment check.
|
|
19
19
|
* @param {{ name?: unknown, execute?: unknown, [key: string]: unknown }} definition
|
|
@@ -33,7 +33,7 @@ export function createMcpToolWrapper({ identity, generation, admit, drain }) {
|
|
|
33
33
|
*/
|
|
34
34
|
async execute(args, exec) {
|
|
35
35
|
const admitted = await admit(exec?.agent)
|
|
36
|
-
if (!admitted) {
|
|
36
|
+
if (!admitted || (assertPermit && !assertPermit(admitted))) {
|
|
37
37
|
const error = new Error(`MCP server "${identity}" is not enabled for this agent (admission refused at generation ${generation})`)
|
|
38
38
|
throw Object.assign(error, { code: 'mcp-admission-refused' })
|
|
39
39
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// One MCP configuration parser/projection for forms, JSON paste, commands, AI,
|
|
2
|
+
// registry previews and stock-client materialization. Unknown fields fail before
|
|
3
|
+
// any operation side effect; secret values never appear in the returned plan.
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { isSegment } from './store/paths.js'
|
|
6
|
+
import { sanitizeMcpConfigText, markMcpConfigProjection } from './mcp-diagnostics.js'
|
|
7
|
+
|
|
8
|
+
export const MCP_LIMITS = Object.freeze({
|
|
9
|
+
documentBytes: 1024 * 1024,
|
|
10
|
+
batchEntries: 32,
|
|
11
|
+
args: 128,
|
|
12
|
+
env: 64,
|
|
13
|
+
headers: 64,
|
|
14
|
+
planBytes: 256 * 1024,
|
|
15
|
+
toolNamesPreview: 20,
|
|
16
|
+
diagnosticBytes: 8192,
|
|
17
|
+
})
|
|
18
|
+
const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/
|
|
19
|
+
const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
20
|
+
const COMMON = new Set(['transport', 'type', 'serverName', 'toolCallTimeoutMs', 'maxInstructionBytes', 'reconnect', 'failOnStartupError'])
|
|
21
|
+
const ROOT = new Set(['identity', 'label', 'owner', 'incarnation', 'generation', 'configuredAt', 'client', 'transport', 'secretRefs', 'secretValues', ...COMMON, 'command', 'args', 'env', 'cwd', 'url', 'headers', 'secretArgs'])
|
|
22
|
+
const RECONNECT = new Set(['enabled', 'initialDelayMs', 'maxDelayMs', 'maxAttempts'])
|
|
23
|
+
const clone = value => value == null ? value : structuredClone(value)
|
|
24
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
|
|
26
|
+
export class McpConfigError extends TypeError {
|
|
27
|
+
constructor(field, message) {
|
|
28
|
+
super(`${field}: ${message}`)
|
|
29
|
+
this.name = 'McpConfigError'
|
|
30
|
+
this.code = 'invalid-config'
|
|
31
|
+
this.field = field
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const fail = (field, message) => { throw new McpConfigError(field, message) }
|
|
35
|
+
const known = (value, allowed, path) => {
|
|
36
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${path}.${key}`, 'unknown field')
|
|
37
|
+
}
|
|
38
|
+
const positive = (value, field, integer = false) => {
|
|
39
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || (integer && !Number.isSafeInteger(value))) fail(field, 'must be a positive finite number')
|
|
40
|
+
return value
|
|
41
|
+
}
|
|
42
|
+
const ref = (value, field) => {
|
|
43
|
+
if (!object(value) || typeof value.ref !== 'string' || !CREDENTIAL_REF.test(value.ref)) fail(field, 'must be a POSIX credential reference')
|
|
44
|
+
known(value, new Set(['ref', 'prefix']), field)
|
|
45
|
+
if (value.prefix !== undefined && typeof value.prefix !== 'string') fail(`${field}.prefix`, 'must be a string')
|
|
46
|
+
return { ref: value.ref, ...(value.prefix ? { prefix: value.prefix } : {}) }
|
|
47
|
+
}
|
|
48
|
+
const sensitiveUrl = value => {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(value)
|
|
51
|
+
return Boolean(url.username || url.password || url.search || url.hash)
|
|
52
|
+
} catch { return true }
|
|
53
|
+
}
|
|
54
|
+
const safeHttpUrl = (value, field) => {
|
|
55
|
+
let url
|
|
56
|
+
try { url = new URL(value) } catch { fail(field, 'must be an absolute URL') }
|
|
57
|
+
if (!['http:', 'https:'].includes(url.protocol)) fail(field, 'only http/https URLs are supported')
|
|
58
|
+
return url
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reconnectOf(value) {
|
|
62
|
+
if (value === undefined) return undefined
|
|
63
|
+
if (!object(value)) fail('reconnect', 'must be an object')
|
|
64
|
+
known(value, RECONNECT, 'reconnect')
|
|
65
|
+
const out = {}
|
|
66
|
+
if (value.enabled !== undefined) {
|
|
67
|
+
if (typeof value.enabled !== 'boolean') fail('reconnect.enabled', 'must be boolean')
|
|
68
|
+
out.enabled = value.enabled
|
|
69
|
+
}
|
|
70
|
+
for (const key of ['initialDelayMs', 'maxDelayMs']) if (value[key] !== undefined) out[key] = positive(value[key], `reconnect.${key}`)
|
|
71
|
+
if (value.maxAttempts !== undefined) out.maxAttempts = positive(value.maxAttempts, 'reconnect.maxAttempts', true)
|
|
72
|
+
if ((out.initialDelayMs ?? 500) > (out.maxDelayMs ?? 30000)) fail('reconnect.initialDelayMs', 'must not exceed maxDelayMs')
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function secretInput(value, field, surface) {
|
|
77
|
+
if (!object(value)) {
|
|
78
|
+
if (value === '') return { action: 'keep' }
|
|
79
|
+
if (surface !== 'ui') fail(field, 'literal secrets are accepted only by the Host mutation channel; use a credential reference')
|
|
80
|
+
if (typeof value !== 'string') fail(field, 'secret values must be strings')
|
|
81
|
+
return { action: 'replace', value }
|
|
82
|
+
}
|
|
83
|
+
known(value, new Set(['action', 'value', 'prefix']), field)
|
|
84
|
+
const action = value.action ?? 'replace'
|
|
85
|
+
if (value.prefix !== undefined && typeof value.prefix !== 'string') fail(`${field}.prefix`, 'must be a string')
|
|
86
|
+
if (!['keep', 'replace', 'remove'].includes(action)) fail(`${field}.action`, 'must be keep, replace or remove')
|
|
87
|
+
if (action === 'replace') {
|
|
88
|
+
if (surface !== 'ui') fail(field, 'literal secrets are accepted only by the Host mutation channel; use a credential reference')
|
|
89
|
+
if (typeof value.value !== 'string' || value.value.length === 0) fail(`${field}.value`, 'replacement secrets must be non-empty strings; blank means keep')
|
|
90
|
+
}
|
|
91
|
+
return { action, ...(action === 'replace' ? { value: value.value, ...(value.prefix !== undefined ? { prefix: value.prefix } : {}) } : {}) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function secretMap(input, field, limit, surface) {
|
|
95
|
+
if (input === undefined) return { refs: {}, values: {}, literals: {}, legacy: {} }
|
|
96
|
+
if (!object(input)) fail(field, 'must be an object')
|
|
97
|
+
const entries = Object.entries(input)
|
|
98
|
+
if (entries.length > limit) fail(field, `exceeds the ${limit}-entry limit`)
|
|
99
|
+
const refs = {}, values = {}, literals = {}, legacy = {}
|
|
100
|
+
for (const [key, value] of entries) {
|
|
101
|
+
if (!key || Buffer.byteLength(key) > 256) fail(field, 'contains an invalid key')
|
|
102
|
+
if (typeof value === 'string') {
|
|
103
|
+
// New UI/JSON literal env/header values are staged credentials. The
|
|
104
|
+
// registry adapter may explicitly preserve a legacy record instead.
|
|
105
|
+
if (surface === 'legacy') legacy[key] = value
|
|
106
|
+
else if (surface === 'refs' && value !== '') fail(`${field}.${key}`, 'literal secrets are not accepted; use a credential reference')
|
|
107
|
+
else values[key] = value === '' ? { action: 'keep' } : { action: 'replace', value }
|
|
108
|
+
} else if (object(value) && typeof value.ref === 'string') refs[key] = ref(value, `${field}.${key}`)
|
|
109
|
+
else values[key] = secretInput(value, `${field}.${key}`, surface)
|
|
110
|
+
}
|
|
111
|
+
return { refs, values, literals, legacy }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function refsOf(input, field) {
|
|
115
|
+
if (!object(input)) return {}
|
|
116
|
+
known(input, new Set(['env', 'headers', 'args', 'url']), field)
|
|
117
|
+
const out = {}
|
|
118
|
+
for (const kind of ['env', 'headers', 'args']) {
|
|
119
|
+
if (input[kind] === undefined) continue
|
|
120
|
+
if (!object(input[kind])) fail(`${field}.${kind}`, 'must be an object')
|
|
121
|
+
out[kind] = {}
|
|
122
|
+
for (const [key, value] of Object.entries(input[kind])) out[kind][key] = ref(value, `${field}.${kind}.${key}`)
|
|
123
|
+
}
|
|
124
|
+
if (input.url !== undefined) out.url = ref(input.url, `${field}.url`)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeTransport(input, client) {
|
|
129
|
+
const kind = client.transport ?? client.type ?? input.transport?.kind ?? (typeof client.command === 'string' ? 'stdio' : typeof client.url === 'string' || object(client.url) ? 'streamable-http' : undefined)
|
|
130
|
+
if (kind === 'http') return 'streamable-http'
|
|
131
|
+
if (kind !== 'stdio' && kind !== 'streamable-http') fail('transport', 'must be stdio or streamable-http')
|
|
132
|
+
return kind
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Normalize one server. surface=ui permits transient secretValues; refs-only surfaces never do. */
|
|
136
|
+
export function normalizeMcpServer(raw, options = {}) {
|
|
137
|
+
if (!object(raw)) fail('server', 'must be an object')
|
|
138
|
+
known(raw, ROOT, 'server')
|
|
139
|
+
const surface = options.surface ?? 'ui'
|
|
140
|
+
if (!['ui', 'refs', 'legacy'].includes(surface)) fail('surface', 'unknown input surface')
|
|
141
|
+
const clientInput = raw.client ?? raw
|
|
142
|
+
if (!object(clientInput)) fail('client', 'must be an object')
|
|
143
|
+
const kind = normalizeTransport(raw, clientInput)
|
|
144
|
+
const allowed = new Set(kind === 'stdio' ? ['command', 'args', 'env', 'cwd', 'secretArgs'] : ['url', 'headers'])
|
|
145
|
+
known(clientInput, new Set([...COMMON, ...allowed, 'identity', 'label', 'owner', 'incarnation', 'generation', 'configuredAt', 'client', 'secretRefs', 'secretValues']), 'client')
|
|
146
|
+
const identity = options.identity ?? raw.identity
|
|
147
|
+
if (!isSegment(identity)) fail('identity', 'must be a stable identity segment')
|
|
148
|
+
const label = raw.label ?? identity
|
|
149
|
+
if (typeof label !== 'string' || !label.trim() || Buffer.byteLength(label) > 512) fail('label', 'must be a non-empty bounded string')
|
|
150
|
+
const serverName = clientInput.serverName ?? identity
|
|
151
|
+
if (typeof serverName !== 'string' || !SERVER_NAME.test(serverName)) fail('serverName', 'must match [A-Za-z0-9_-]{1,32}')
|
|
152
|
+
const client = { transport: kind, serverName, failOnStartupError: true }
|
|
153
|
+
for (const key of ['toolCallTimeoutMs', 'maxInstructionBytes']) if (clientInput[key] !== undefined) client[key] = positive(clientInput[key], key, key === 'maxInstructionBytes')
|
|
154
|
+
const reconnect = reconnectOf(clientInput.reconnect)
|
|
155
|
+
if (reconnect) client.reconnect = reconnect
|
|
156
|
+
const secretRefs = { env: {}, headers: {}, args: {} }
|
|
157
|
+
const secretValues = { env: {}, headers: {}, args: {} }
|
|
158
|
+
const legacy = { env: {}, headers: {} }
|
|
159
|
+
const existingRefs = refsOf(raw.secretRefs, 'secretRefs')
|
|
160
|
+
Object.assign(secretRefs.env, existingRefs.env ?? {})
|
|
161
|
+
Object.assign(secretRefs.headers, existingRefs.headers ?? {})
|
|
162
|
+
Object.assign(secretRefs.args, existingRefs.args ?? {})
|
|
163
|
+
if (existingRefs.url) secretRefs.url = existingRefs.url
|
|
164
|
+
|
|
165
|
+
if (kind === 'stdio') {
|
|
166
|
+
if (clientInput.url !== undefined || clientInput.headers !== undefined) fail('client', 'HTTP fields cannot be combined with stdio')
|
|
167
|
+
if (typeof clientInput.command !== 'string' || !clientInput.command.trim()) fail('command', 'is required')
|
|
168
|
+
client.command = clientInput.command
|
|
169
|
+
const args = clientInput.args ?? []
|
|
170
|
+
if (!Array.isArray(args) || args.length > MCP_LIMITS.args) fail('args', `must be an array of at most ${MCP_LIMITS.args} entries`)
|
|
171
|
+
client.args = args.map((value, index) => {
|
|
172
|
+
if (typeof value === 'string') return value
|
|
173
|
+
if (object(value) && typeof value.ref === 'string') {
|
|
174
|
+
secretRefs.args[index] = ref(value, `args.${index}`)
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
fail(`args.${index}`, 'must be a string or credential reference')
|
|
178
|
+
})
|
|
179
|
+
if (clientInput.secretArgs !== undefined) {
|
|
180
|
+
if (!object(clientInput.secretArgs)) fail('secretArgs', 'must be an object keyed by argument index')
|
|
181
|
+
for (const [key, value] of Object.entries(clientInput.secretArgs)) {
|
|
182
|
+
const index = Number(key)
|
|
183
|
+
if (!Number.isInteger(index) || index < 0 || index >= client.args.length) fail(`secretArgs.${key}`, 'argument index is out of range')
|
|
184
|
+
if (object(value) && typeof value.ref === 'string') secretRefs.args[index] = ref(value, `secretArgs.${key}`)
|
|
185
|
+
else secretValues.args[index] = secretInput(value, `secretArgs.${key}`, surface)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const env = secretMap(clientInput.env, 'env', MCP_LIMITS.env, surface)
|
|
189
|
+
Object.assign(secretRefs.env, env.refs)
|
|
190
|
+
Object.assign(secretValues.env, env.values)
|
|
191
|
+
Object.assign(legacy.env, env.legacy)
|
|
192
|
+
if (clientInput.cwd !== undefined) {
|
|
193
|
+
if (typeof clientInput.cwd !== 'string') fail('cwd', 'must be a string')
|
|
194
|
+
client.cwd = clientInput.cwd
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
if (clientInput.command !== undefined || clientInput.args !== undefined || clientInput.env !== undefined || clientInput.cwd !== undefined || clientInput.secretArgs !== undefined) fail('client', 'stdio fields cannot be combined with streamable-http')
|
|
198
|
+
if (object(clientInput.url) && typeof clientInput.url.ref === 'string') secretRefs.url = ref(clientInput.url, 'url')
|
|
199
|
+
else if (typeof clientInput.url === 'string') {
|
|
200
|
+
const url = safeHttpUrl(clientInput.url, 'url')
|
|
201
|
+
if (sensitiveUrl(clientInput.url)) {
|
|
202
|
+
if (surface !== 'ui') fail('url', 'sensitive URLs require a credential reference')
|
|
203
|
+
secretValues.url = { action: 'replace', value: clientInput.url }
|
|
204
|
+
} else client.url = url.toString()
|
|
205
|
+
} else if (clientInput.url !== undefined) secretValues.url = secretInput(clientInput.url, 'url', surface)
|
|
206
|
+
else if (!secretRefs.url) fail('url', 'is required')
|
|
207
|
+
const headers = secretMap(clientInput.headers, 'headers', MCP_LIMITS.headers, surface)
|
|
208
|
+
Object.assign(secretRefs.headers, headers.refs)
|
|
209
|
+
Object.assign(secretValues.headers, headers.values)
|
|
210
|
+
Object.assign(legacy.headers, headers.legacy)
|
|
211
|
+
}
|
|
212
|
+
if (raw.secretValues !== undefined) {
|
|
213
|
+
if (!object(raw.secretValues)) fail('secretValues', 'must be an object')
|
|
214
|
+
known(raw.secretValues, new Set(['env', 'headers', 'args', 'url']), 'secretValues')
|
|
215
|
+
for (const [field, limit] of [['env', MCP_LIMITS.env], ['headers', MCP_LIMITS.headers], ['args', MCP_LIMITS.args]]) {
|
|
216
|
+
const values = raw.secretValues[field]
|
|
217
|
+
if (values === undefined) continue
|
|
218
|
+
if (!object(values)) fail(`secretValues.${field}`, 'must be an object')
|
|
219
|
+
if (Object.keys(values).length > limit) fail(`secretValues.${field}`, `exceeds the ${limit}-entry limit`)
|
|
220
|
+
for (const [key, value] of Object.entries(values)) secretValues[field][key] = secretInput(value, `secretValues.${field}.${key}`, surface)
|
|
221
|
+
}
|
|
222
|
+
if (raw.secretValues.url !== undefined) secretValues.url = secretInput(raw.secretValues.url, 'secretValues.url', surface)
|
|
223
|
+
}
|
|
224
|
+
const hasRefs = Object.values(secretRefs).some(value => object(value) && Object.keys(value).length > 0)
|
|
225
|
+
const hasLegacy = Object.keys(legacy.env).length > 0 || Object.keys(legacy.headers).length > 0
|
|
226
|
+
const entry = {
|
|
227
|
+
identity,
|
|
228
|
+
label,
|
|
229
|
+
owner: clone(raw.owner ?? options.owner ?? { kind: 'global', key: 'installation' }),
|
|
230
|
+
...(raw.incarnation !== undefined ? { incarnation: raw.incarnation } : {}),
|
|
231
|
+
generation: raw.generation ?? 0,
|
|
232
|
+
configuredAt: raw.configuredAt ?? 0,
|
|
233
|
+
transport: { kind, ref: kind === 'stdio' ? `cmd:${client.command}` : client.url ? `http:${client.url}` : 'http:credential-ref' },
|
|
234
|
+
client: { ...client, ...(kind === 'stdio' ? { env: legacy.env } : { headers: legacy.headers }) },
|
|
235
|
+
secretRefs,
|
|
236
|
+
}
|
|
237
|
+
if (Object.keys(secretRefs.env).length === 0) delete entry.secretRefs.env
|
|
238
|
+
if (Object.keys(secretRefs.headers).length === 0) delete entry.secretRefs.headers
|
|
239
|
+
if (Object.keys(secretRefs.args).length === 0) delete entry.secretRefs.args
|
|
240
|
+
if (Object.keys(entry.secretRefs).length === 0) delete entry.secretRefs
|
|
241
|
+
const transient = Object.keys(secretValues.env).length || Object.keys(secretValues.headers).length || Object.keys(secretValues.args).length || secretValues.url
|
|
242
|
+
return {
|
|
243
|
+
entry,
|
|
244
|
+
secretValues: transient ? secretValues : {},
|
|
245
|
+
hasSecrets: Boolean(hasRefs || hasLegacy || transient),
|
|
246
|
+
legacyLiteralSecrets: hasLegacy,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function parseMcpDocument(input, options = {}) {
|
|
251
|
+
let document = input
|
|
252
|
+
if (typeof input === 'string') {
|
|
253
|
+
if (Buffer.byteLength(input) > MCP_LIMITS.documentBytes) fail('document', `exceeds the ${MCP_LIMITS.documentBytes}-byte limit`)
|
|
254
|
+
try { document = JSON.parse(input) } catch { fail('document', 'must be valid JSON') }
|
|
255
|
+
}
|
|
256
|
+
if (!object(document)) fail('document', 'must be an object')
|
|
257
|
+
const batch = Object.hasOwn(document, 'mcpServers')
|
|
258
|
+
if (batch) {
|
|
259
|
+
known(document, new Set(['mcpServers']), 'document')
|
|
260
|
+
if (!object(document.mcpServers)) fail('mcpServers', 'must be an object')
|
|
261
|
+
}
|
|
262
|
+
const entries = batch ? Object.entries(document.mcpServers) : [[undefined, document]]
|
|
263
|
+
if (entries.length < 1 || entries.length > MCP_LIMITS.batchEntries) fail('mcpServers', `must contain between 1 and ${MCP_LIMITS.batchEntries} servers`)
|
|
264
|
+
const identities = new Set()
|
|
265
|
+
const normalized = entries.map(([key, value], index) => {
|
|
266
|
+
const identity = key ?? value?.identity
|
|
267
|
+
if (identities.has(identity)) fail(`mcpServers.${identity}`, 'duplicate identity')
|
|
268
|
+
identities.add(identity)
|
|
269
|
+
try {
|
|
270
|
+
return normalizeMcpServer(value, { ...options, identity: identity ?? options.identity })
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof McpConfigError) throw new McpConfigError(batch ? `mcpServers.${key ?? index}.${error.field}` : error.field, error.message.slice(error.message.indexOf(': ') + 2))
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
const plan = normalized.map((item, index) => ({ index, identity: item.entry.identity, entry: redactMcpEntry(item.entry), fingerprint: fingerprintMcpEntry(item.entry) }))
|
|
277
|
+
assertMcpPlanSize(plan)
|
|
278
|
+
const result = { plan, digest: createHash('sha256').update(JSON.stringify(plan)).digest('hex') }
|
|
279
|
+
// Execution-only normalized entries (including transient secretValues) stay
|
|
280
|
+
// reachable to Host operations but can never leak into a serialized preview.
|
|
281
|
+
Object.defineProperty(result, 'entries', { value: normalized, enumerable: false })
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Display/edit projection of a stored entry. Only structurally secret positions
|
|
287
|
+
* are replaced: env/header values, secretRefs-indexed args and sensitive URLs.
|
|
288
|
+
* Every other string keeps its exact value apart from known secrets and
|
|
289
|
+
* sensitive URL tails, so plain argv such as `--port=8080` or `FOO=bar` survives
|
|
290
|
+
* an edit round-trip and an approval card byte-identical.
|
|
291
|
+
*/
|
|
292
|
+
export function redactMcpEntry(entry, secrets = []) {
|
|
293
|
+
const out = clone(entry)
|
|
294
|
+
if (out?.client?.env) for (const key of Object.keys(out.client.env)) out.client.env[key] = MCP_REDACTED
|
|
295
|
+
if (out?.client?.headers) for (const key of Object.keys(out.client.headers)) out.client.headers[key] = MCP_REDACTED
|
|
296
|
+
for (const index of Object.keys(out?.secretRefs?.args ?? {})) if (out.client?.args?.[index] !== undefined) out.client.args[index] = MCP_REDACTED
|
|
297
|
+
if (out?.client?.url && sensitiveUrl(out.client.url)) out.client.url = '[redacted-url]'
|
|
298
|
+
delete out.secretValues
|
|
299
|
+
return markMcpConfigProjection(sanitizeMcpConfigProjection(out, secrets))
|
|
300
|
+
}
|
|
301
|
+
function sanitizeMcpConfigProjection(value, secrets) {
|
|
302
|
+
if (typeof value === 'string') return sanitizeMcpConfigText(value, secrets)
|
|
303
|
+
if (Array.isArray(value)) return value.map(item => sanitizeMcpConfigProjection(item, secrets))
|
|
304
|
+
if (object(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeMcpConfigProjection(item, secrets)]))
|
|
305
|
+
return value
|
|
306
|
+
}
|
|
307
|
+
export const MCP_REDACTED = '[redacted]'
|
|
308
|
+
const PLACEHOLDER = /\[redacted(?:-[a-z]+)?\]/
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Edit input for stdio args is resolved against the stored record: an element
|
|
312
|
+
* equal to the redaction projection at the same index means "unchanged" and
|
|
313
|
+
* takes the stored value back (secret slots stay '' with their refs). Any other
|
|
314
|
+
* placeholder is a redacted value round-tripped as literal input and is refused
|
|
315
|
+
* instead of being saved.
|
|
316
|
+
*/
|
|
317
|
+
export function resolveMcpEditArgs(existing, incoming) {
|
|
318
|
+
if (!Array.isArray(incoming)) return incoming
|
|
319
|
+
const stored = existing?.client?.args ?? []
|
|
320
|
+
const projected = redactMcpEntry(existing)?.client?.args ?? []
|
|
321
|
+
return incoming.map((value, index) => {
|
|
322
|
+
if (typeof value !== 'string') return value
|
|
323
|
+
if (index < stored.length && value === projected[index]) return stored[index]
|
|
324
|
+
if (PLACEHOLDER.test(value)) fail(`args.${index}`, 'contains a redaction placeholder; omit args to keep the stored arguments or enter the real value')
|
|
325
|
+
return value
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Hash the full record (including legacy literals) without returning those literals. */
|
|
330
|
+
export function fingerprintMcpEntry(entry) {
|
|
331
|
+
return createHash('sha256').update(JSON.stringify(entry ?? null)).digest('hex')
|
|
332
|
+
}
|
|
333
|
+
export function assertMcpPlanSize(plan) {
|
|
334
|
+
const bytes = Buffer.byteLength(JSON.stringify(plan))
|
|
335
|
+
if (bytes > MCP_LIMITS.planBytes) fail('plan', `exceeds the ${MCP_LIMITS.planBytes}-byte limit`)
|
|
336
|
+
return bytes
|
|
337
|
+
}
|
|
338
|
+
export function hasMcpSecrets(entry) {
|
|
339
|
+
if (entry?.secretRefs && Object.keys(entry.secretRefs).length > 0) return true
|
|
340
|
+
if (Object.keys(entry?.client?.env ?? {}).length > 0 || Object.keys(entry?.client?.headers ?? {}).length > 0) return true
|
|
341
|
+
return typeof entry?.client?.url === 'string' && sensitiveUrl(entry.client.url)
|
|
342
|
+
}
|
|
343
|
+
export function isSensitiveMcpUrl(value) { return sensitiveUrl(value) }
|
|
344
|
+
export function validateCredentialRef(value) { return typeof value === 'string' && CREDENTIAL_REF.test(value) }
|