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.
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +105 -7
- package/lib/client.mcp-manager.js +563 -0
- package/package.json +1 -1
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +40 -9
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +575 -146
- package/src/capabilities/mcp-mount.js +220 -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 +170 -57
- package/src/file-lease/client.js +268 -35
- package/src/file-lease/index.js +4 -0
- package/src/file-lease/plugin.js +239 -15
- package/src/file-lease/tools.js +426 -38
- package/src/file-lease/watch.js +473 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/protocol.js +3 -2
- package/src/lease-broker/server.js +6 -3
- package/src/lease-broker/state.js +68 -11
- package/src/process/index.js +4 -2
- package/src/shared/audit.js +3 -0
- package/src/shared/child-scope.js +2 -0
- package/src/worktree/rules.js +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mcp-server-setup
|
|
3
|
+
description: "Install, add, or configure a new MCP server with credential-safe preview, exact Host confirmation, strict discovery, and separate session Apply. Do not use for merely calling or discussing an existing MCP tool."
|
|
4
|
+
metadata:
|
|
5
|
+
short-description: Credential-safe MCP server installation
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# MCP Server Setup
|
|
9
|
+
|
|
10
|
+
Use this workflow for genuine requests to install, add, or configure a new MCP
|
|
11
|
+
server. It is not a tool-repair or general MCP discussion workflow.
|
|
12
|
+
|
|
13
|
+
## Boundaries
|
|
14
|
+
|
|
15
|
+
- Weir supports `stdio` and `streamable-http` only. Do not propose legacy SSE,
|
|
16
|
+
OAuth flows, a marketplace, or adoption of an unmanaged server.
|
|
17
|
+
- Never ask the user to paste a token, password, private endpoint, or other
|
|
18
|
+
secret into chat. Use an existing DSH credential reference such as
|
|
19
|
+
`{ "ref": "GITHUB_TOKEN" }`. A reference may include a non-secret `prefix`.
|
|
20
|
+
- Every environment or HTTP-header value on this tool surface is a credential
|
|
21
|
+
reference. Mark each sensitive stdio argument with a reference object instead
|
|
22
|
+
of placing its value in a plain argument string.
|
|
23
|
+
- Any URL containing userinfo, a query, or a fragment is sensitive. Supply the
|
|
24
|
+
whole URL through a credential reference; do not put it in the plan.
|
|
25
|
+
- Installation never selects the server, changes a default, or Applies a
|
|
26
|
+
capability set. Do not imply that a mounted server is already authorized.
|
|
27
|
+
|
|
28
|
+
## 1. Establish the plan
|
|
29
|
+
|
|
30
|
+
Read the server author's current documentation when it is available. Identify
|
|
31
|
+
the transport, executable or endpoint, non-secret arguments, required credential
|
|
32
|
+
references, working directory, and relevant timeout/reconnect options. Keep the
|
|
33
|
+
plan within the `mcp_install` schema. Unknown fields and cross-transport fields
|
|
34
|
+
are rejected.
|
|
35
|
+
|
|
36
|
+
For stdio, `command` and non-secret `args` describe the process. For
|
|
37
|
+
`streamable-http`, use `url` and credential-referenced `headers`; stdio fields
|
|
38
|
+
are forbidden. `failOnStartupError` is not configurable: initial mounting is
|
|
39
|
+
always strict. Discovering zero tools is still a valid result.
|
|
40
|
+
|
|
41
|
+
Example stdio plan shape (references, not secrets):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"action": "preview",
|
|
46
|
+
"plan": {
|
|
47
|
+
"identity": "github",
|
|
48
|
+
"label": "GitHub",
|
|
49
|
+
"transport": "stdio",
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "@example/mcp-server", { "ref": "MCP_CLI_TOKEN" }],
|
|
52
|
+
"env": { "GITHUB_TOKEN": { "ref": "GITHUB_TOKEN" } }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Example HTTP plan shape:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"action": "preview",
|
|
62
|
+
"plan": {
|
|
63
|
+
"identity": "private-docs",
|
|
64
|
+
"transport": "streamable-http",
|
|
65
|
+
"url": { "ref": "PRIVATE_MCP_URL" },
|
|
66
|
+
"headers": {
|
|
67
|
+
"Authorization": { "ref": "PRIVATE_MCP_TOKEN", "prefix": "Bearer " }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 2. Preview before installation
|
|
74
|
+
|
|
75
|
+
Call `mcp_install` with `action: "preview"` and the plan. Check the bounded
|
|
76
|
+
redacted plan, digest, target conflicts, namespace conflicts, capabilities, and
|
|
77
|
+
validation outcome. Preview does not start a process, contact the server, write
|
|
78
|
+
credentials or configuration, or change selection. Correct the plan and preview
|
|
79
|
+
again when necessary.
|
|
80
|
+
|
|
81
|
+
`mcp_install` is single-server and fresh-identity only. Do not use it to edit,
|
|
82
|
+
restart, remove, batch-import, or set a default. Use the management UI or the
|
|
83
|
+
shared `/capabilities` MCP commands for those operations.
|
|
84
|
+
|
|
85
|
+
## 3. Let the Host obtain approval
|
|
86
|
+
|
|
87
|
+
After a clean preview, call `mcp_install` with `action: "install"` and the same
|
|
88
|
+
plan. The Host displays the exact redacted plan, its digest, and process or
|
|
89
|
+
network effects. Only the user can approve that card. There is no `confirmed`
|
|
90
|
+
argument; chat text, custom answers, and extra selections are not approval.
|
|
91
|
+
|
|
92
|
+
Cancellation, close, timeout, missing confirmation support, delegated callers,
|
|
93
|
+
ownership changes, plan changes, registry changes, and namespace changes stop
|
|
94
|
+
before probe or write. Report the outcome and request a fresh preview when
|
|
95
|
+
required; never fabricate approval or retry an unchanged conflict in a loop.
|
|
96
|
+
|
|
97
|
+
## 4. Interpret the actual outcome
|
|
98
|
+
|
|
99
|
+
An approved install probes before registration. A failed probe does not create
|
|
100
|
+
a registry entry. A mounted result reports the real identity, incarnation,
|
|
101
|
+
generation, and bounded discovery summary.
|
|
102
|
+
|
|
103
|
+
- `mounted`: registration and strict mount succeeded.
|
|
104
|
+
- `saved-degraded`: configuration was saved (`saved: true`) but strict mounting
|
|
105
|
+
failed. This is not installation success; offer Edit, Test, or Restart.
|
|
106
|
+
- `owner-changed` or `indeterminate`: inspect actual registry/mount facts. Never
|
|
107
|
+
claim rollback or zero effects.
|
|
108
|
+
- `cleanup-failed` / `cleanup-unknown`: follow the recovery action; do not start
|
|
109
|
+
an immediate same-name replacement.
|
|
110
|
+
|
|
111
|
+
Do not run an arbitrary discovered tool as an installation test. Discovery and
|
|
112
|
+
the reported mount outcome are the evidence.
|
|
113
|
+
|
|
114
|
+
## 5. Keep authorization separate
|
|
115
|
+
|
|
116
|
+
After a mounted result, direct the user to the separate **Select for this
|
|
117
|
+
session** action and explicit **Apply**. Installation and the optional
|
|
118
|
+
new-session default are independent of the current draft and accepted sets.
|
|
119
|
+
Editing or restarting preserves the registration incarnation; removing and
|
|
120
|
+
re-adding the same identity creates a new one and requires reauthorization.
|
|
121
|
+
|
|
122
|
+
Credential rotation takes effect on the next explicit probe or restart, not as
|
|
123
|
+
a claimed live reconnect. Local credential storage avoids registry/profile and
|
|
124
|
+
transcript disclosure; it is not an isolation boundary against a same-UID agent.
|
|
@@ -203,7 +203,7 @@ export function digestApplyRequest(normalized) {
|
|
|
203
203
|
*/
|
|
204
204
|
export function createApplyEngine(options) {
|
|
205
205
|
const {
|
|
206
|
-
store, locateSession, inventory, provider,
|
|
206
|
+
store, locateSession, inventory, provider, mcpRegistry,
|
|
207
207
|
fence = createAdmissionFence(),
|
|
208
208
|
drain = createDrainCoordinator(),
|
|
209
209
|
conditions = () => [],
|
|
@@ -365,6 +365,19 @@ export function createApplyEngine(options) {
|
|
|
365
365
|
const missing = normalized.skills.filter(key => (byKey.get(key) ?? []).length !== 1)
|
|
366
366
|
if (missing.length) return { status: 'rejected', reason: 'selected-identity-missing', step: 2, missing }
|
|
367
367
|
|
|
368
|
+
let mcpIncarnations
|
|
369
|
+
if (mcpRegistry) {
|
|
370
|
+
let current
|
|
371
|
+
try { current = await mcpRegistry.read() } catch (error) {
|
|
372
|
+
return { status: 'rejected', reason: `mcp-registry-unavailable:${message(error)}`, step: 2 }
|
|
373
|
+
}
|
|
374
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'rejected', reason: `mcp-registry-unavailable:${current.kind}`, step: 2 }
|
|
375
|
+
const servers = current.kind === 'ok' ? current.servers : {}
|
|
376
|
+
const missingMcp = normalized.mcpServers.filter(identity => !servers[identity])
|
|
377
|
+
if (missingMcp.length) return { status: 'rejected', reason: 'unregistered-mcp', step: 2, missing: missingMcp }
|
|
378
|
+
mcpIncarnations = Object.fromEntries(normalized.mcpServers.map(identity => [identity, servers[identity].incarnation ?? null]))
|
|
379
|
+
}
|
|
380
|
+
|
|
368
381
|
// Step 3: prepare a pure snapshot, filtered view and verified content
|
|
369
382
|
// handles. Nothing is published, installed or notified here.
|
|
370
383
|
let prepared
|
|
@@ -388,7 +401,7 @@ export function createApplyEngine(options) {
|
|
|
388
401
|
|
|
389
402
|
const previousSets = record.kind === 'ok' ? safeSets(record.payload) : { skills: [], mcpServers: [] }
|
|
390
403
|
const removedServers = previousSets.mcpServers.filter(server => !normalized.mcpServers.includes(server))
|
|
391
|
-
const payload = { skills: prepared.identities, mcpServers: normalized.mcpServers, origin: 'apply' }
|
|
404
|
+
const payload = { skills: prepared.identities, mcpServers: normalized.mcpServers, ...(mcpIncarnations ? { mcpIncarnations } : {}), origin: 'apply' }
|
|
392
405
|
const requestDigest = digestApplyRequest(normalized)
|
|
393
406
|
|
|
394
407
|
// Step 4: enter the admission fence, close removed-server gates, start draining.
|
|
@@ -496,7 +509,7 @@ export function createApplyEngine(options) {
|
|
|
496
509
|
status: 'applied',
|
|
497
510
|
revision: result.revision,
|
|
498
511
|
receipt: result.receipt,
|
|
499
|
-
effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers] },
|
|
512
|
+
effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers], ...(mcpIncarnations ? { mcpIncarnations } : {}) },
|
|
500
513
|
unresolvedWarnings: normalized.unresolved.map(entry => ({ ...entry })),
|
|
501
514
|
...(draining.length ? { draining } : {}),
|
|
502
515
|
...(warnings.length ? { warnings } : {}),
|
|
@@ -564,6 +577,7 @@ export function createApplyEngine(options) {
|
|
|
564
577
|
return {
|
|
565
578
|
skills: Array.isArray(value.skills) ? value.skills : [],
|
|
566
579
|
mcpServers: Array.isArray(value.mcpServers) ? /** @type {string[]} */ (value.mcpServers) : [],
|
|
580
|
+
...(value.mcpIncarnations && typeof value.mcpIncarnations === 'object' && !Array.isArray(value.mcpIncarnations) ? { mcpIncarnations: structuredClone(value.mcpIncarnations) } : {}),
|
|
567
581
|
}
|
|
568
582
|
}
|
|
569
583
|
|
|
@@ -613,7 +627,7 @@ export function createApplyEngine(options) {
|
|
|
613
627
|
// just-committed record — reported, never fatal.
|
|
614
628
|
try {
|
|
615
629
|
const sets = safeSets(payload)
|
|
616
|
-
publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers] })
|
|
630
|
+
publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers], ...(sets.mcpIncarnations ? { mcpIncarnations: structuredClone(sets.mcpIncarnations) } : {}) })
|
|
617
631
|
} catch (error) {
|
|
618
632
|
degraded('snapshot-publication-degraded', error)
|
|
619
633
|
}
|
|
@@ -21,6 +21,7 @@ import { openCapabilityStore } from './store/store.js'
|
|
|
21
21
|
import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPresetsPayload, buildDefaultGetPayload } from './read-payloads.js'
|
|
22
22
|
import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
|
|
23
23
|
import { createAudit } from '../shared/audit.js'
|
|
24
|
+
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
24
25
|
|
|
25
26
|
/** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
|
|
26
27
|
export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
|
|
@@ -71,15 +72,74 @@ export function capabilityReadBridge(presetId) {
|
|
|
71
72
|
return feeds?.size === 1 ? [...feeds][0].faces : null
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The isolated `weirMcpManager` owned by the exact preset revision a live
|
|
77
|
+
* session's Agent retains, or undefined when that cannot be proven. The
|
|
78
|
+
* registry's `serviceFor(agent, name)` reads `agent.ctx`'s retained mount, so
|
|
79
|
+
* an old session keeps resolving its old revision after a config change or a
|
|
80
|
+
* bundle toggle remounted the preset next to it. Everything is read through
|
|
81
|
+
* caller-supplied ctx getters (never a static runtime import).
|
|
82
|
+
* @param {string} sessionId
|
|
83
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
|
|
84
|
+
*/
|
|
85
|
+
function sessionManager(sessionId, runtime) {
|
|
86
|
+
try {
|
|
87
|
+
const agent = runtime?.agents?.()?.get?.(sessionId)
|
|
88
|
+
// A cold or unknown session has no live Agent and hence no retained mount.
|
|
89
|
+
if (!agent?.ctx || agent.id !== sessionId) return undefined
|
|
90
|
+
const registry = runtime?.agentPresets?.()
|
|
91
|
+
if (typeof registry?.serviceFor !== 'function') return undefined
|
|
92
|
+
return registry.serviceFor(agent, 'weirMcpManager') ?? undefined
|
|
93
|
+
} catch { return undefined }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the feed owning ONE session's read/mutation.
|
|
98
|
+
*
|
|
99
|
+
* A single feed is the fast path (no agent lookup). Several feeds for one
|
|
100
|
+
* preset are legitimate — two retained revisions of the same preset stay
|
|
101
|
+
* mounted while old sessions still use the old one — so the owner is the
|
|
102
|
+
* unique feed whose `faces.mcpManager()` IS the manager the session's own
|
|
103
|
+
* revision provides. Every other outcome fails closed with `owner-ambiguous`:
|
|
104
|
+
* no runtime seam, a cold/unknown agent, a revision without a manager, no
|
|
105
|
+
* matching feed, or several feeds sharing that manager (overlapping
|
|
106
|
+
* generations of one revision). Never newest-wins.
|
|
107
|
+
* @param {string} presetId
|
|
108
|
+
* @param {string} sessionId
|
|
109
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
|
|
110
|
+
*/
|
|
111
|
+
function sessionFeed(presetId, sessionId, runtime) {
|
|
75
112
|
const feeds = bridgeFeeds.get(presetId)
|
|
76
113
|
if (!feeds?.size) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
|
|
77
|
-
if (feeds.size
|
|
78
|
-
|
|
114
|
+
if (feeds.size === 1) return [...feeds][0]
|
|
115
|
+
const ambiguous = reason => new CapabilityReadError('owner-ambiguous', `capability read: overlapping feeds for preset ${presetId}: ${reason}`)
|
|
116
|
+
const manager = sessionManager(sessionId, runtime)
|
|
117
|
+
if (manager === undefined) throw ambiguous(`session ${JSON.stringify(sessionId)} has no live agent with a preset-owned MCP manager`)
|
|
118
|
+
const matches = [...feeds].filter(feed => {
|
|
119
|
+
try { return feed.faces?.mcpManager?.() === manager } catch { return false }
|
|
120
|
+
})
|
|
121
|
+
if (matches.length === 0) throw ambiguous(`no feed belongs to the revision of session ${JSON.stringify(sessionId)}`)
|
|
122
|
+
if (matches.length > 1) throw ambiguous(`${matches.length} feeds share the manager of session ${JSON.stringify(sessionId)}`)
|
|
123
|
+
return matches[0]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Exact feed token used by mutation ownership snapshots and their rechecks.
|
|
128
|
+
* @param {string} presetId
|
|
129
|
+
* @param {string} sessionId
|
|
130
|
+
* @param {{ agents?: () => any, agentPresets?: () => any }} [runtime] ctx getters for the host `agents` and `agentPresets` services
|
|
131
|
+
*/
|
|
132
|
+
export function capabilityReadFeed(presetId, sessionId, runtime = undefined) { return sessionFeed(presetId, sessionId, runtime) }
|
|
133
|
+
|
|
134
|
+
/** @param {{ bridge?: Function, agents?: () => any, agentPresets?: () => any }} dependencies */
|
|
135
|
+
function feedResolver(dependencies) {
|
|
136
|
+
if (dependencies.bridge) return dependencies.bridge
|
|
137
|
+
const runtime = { agents: dependencies.agents, agentPresets: dependencies.agentPresets }
|
|
138
|
+
return (presetId, sessionId) => sessionFeed(presetId, sessionId, runtime)
|
|
79
139
|
}
|
|
80
140
|
|
|
81
141
|
/** Extract the current projected owner and its last committed selection generation. */
|
|
82
|
-
function
|
|
142
|
+
export function capabilityOwnership(observation, sessionId) {
|
|
83
143
|
if (!observation || observation.header?.id !== sessionId) {
|
|
84
144
|
throw new CapabilityReadError('unknown-session', `capability read: unknown session ${JSON.stringify(sessionId)}`)
|
|
85
145
|
}
|
|
@@ -95,7 +155,7 @@ function ownership(observation, sessionId) {
|
|
|
95
155
|
|
|
96
156
|
/** Create the host read service. Observations are caller-owned leases, always disposed. */
|
|
97
157
|
export function createCapabilityReadService(dependencies = {}) {
|
|
98
|
-
const bridgeFor = dependencies
|
|
158
|
+
const bridgeFor = feedResolver(dependencies)
|
|
99
159
|
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
100
160
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
101
161
|
const onDegraded = dependencies.onDegraded ?? (() => {})
|
|
@@ -105,7 +165,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
105
165
|
try {
|
|
106
166
|
lease = await observeSession(sessionId)
|
|
107
167
|
if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability read: session observation is unavailable')
|
|
108
|
-
return { lease, owner:
|
|
168
|
+
return { lease, owner: capabilityOwnership(lease, sessionId) }
|
|
109
169
|
} catch (cause) {
|
|
110
170
|
lease?.[Symbol.dispose]?.()
|
|
111
171
|
if (cause instanceof CapabilityReadError) throw cause
|
|
@@ -117,7 +177,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
117
177
|
const first = await observe(sessionId)
|
|
118
178
|
try {
|
|
119
179
|
const { presetId, cwd, selectionSeq } = first.owner
|
|
120
|
-
const feed = bridgeFor(presetId)
|
|
180
|
+
const feed = bridgeFor(presetId, sessionId)
|
|
121
181
|
if (!feed) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
|
|
122
182
|
const faces = feed.faces ?? feed
|
|
123
183
|
const options = { cwd, scope: { session: { id: sessionId } } }
|
|
@@ -149,7 +209,7 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
149
209
|
}
|
|
150
210
|
const second = await observe(sessionId)
|
|
151
211
|
try {
|
|
152
|
-
if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId) !== feed) {
|
|
212
|
+
if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId, sessionId) !== feed) {
|
|
153
213
|
throw new CapabilityReadError('owner-changed', 'capability read: owner or feed changed during read')
|
|
154
214
|
}
|
|
155
215
|
return payload
|
|
@@ -185,27 +245,143 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
185
245
|
}
|
|
186
246
|
}
|
|
187
247
|
|
|
248
|
+
export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
|
|
249
|
+
export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
|
|
250
|
+
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
|
|
251
|
+
|
|
252
|
+
export function sameMutationOwner(first, second) {
|
|
253
|
+
const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
|
|
254
|
+
return first.owner.presetId === second.owner.presetId
|
|
255
|
+
&& first.owner.cwd === second.owner.cwd
|
|
256
|
+
&& first.feed === second.feed
|
|
257
|
+
&& profile(first.profile) === profile(second.profile)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
|
|
261
|
+
export function createCapabilityMutationService(dependencies = {}) {
|
|
262
|
+
const bridgeFor = feedResolver(dependencies)
|
|
263
|
+
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
264
|
+
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
265
|
+
const observe = async sessionId => {
|
|
266
|
+
let lease
|
|
267
|
+
try {
|
|
268
|
+
lease = await observeSession(sessionId)
|
|
269
|
+
if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability mutation: session observation unavailable')
|
|
270
|
+
const owner = capabilityOwnership(lease, sessionId)
|
|
271
|
+
const feed = bridgeFor(owner.presetId, sessionId)
|
|
272
|
+
if (!feed) throw new CapabilityReadError('bridge-absent', `capability mutation: no feed for preset ${owner.presetId}`)
|
|
273
|
+
const faces = feed.faces ?? feed
|
|
274
|
+
return { lease, owner, feed, faces, profile: faces.profileContext?.() }
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
lease?.[Symbol.dispose]?.()
|
|
277
|
+
if (cause instanceof CapabilityReadError) throw cause
|
|
278
|
+
throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function mutate(sessionId, request, batch = false) {
|
|
282
|
+
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
283
|
+
const first = await observe(sessionId)
|
|
284
|
+
try {
|
|
285
|
+
const assertOwner = async () => {
|
|
286
|
+
const current = await observe(sessionId)
|
|
287
|
+
try {
|
|
288
|
+
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
289
|
+
} finally { current.lease[Symbol.dispose]() }
|
|
290
|
+
}
|
|
291
|
+
if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
|
|
292
|
+
const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
|
|
293
|
+
let result
|
|
294
|
+
try {
|
|
295
|
+
result = batch ? await operations.executeBatch(request) : request?.action === 'preview' ? await operations.preview(request) : await operations.execute(request)
|
|
296
|
+
} catch (cause) {
|
|
297
|
+
result = { status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed', saved: false, error: mcpDiagnostic(cause, { code: cause?.code ?? 'validation-failed' }) }
|
|
298
|
+
}
|
|
299
|
+
// A response may be stale by the time it is serialized. Never claim a
|
|
300
|
+
// rollback; return the operation's actual saved facts with owner-changed.
|
|
301
|
+
try {
|
|
302
|
+
await assertOwner()
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
if (result.status !== 'owner-changed') {
|
|
305
|
+
return sanitizeMcpPayload({
|
|
306
|
+
...result,
|
|
307
|
+
status: 'owner-changed',
|
|
308
|
+
ownerChanged: true,
|
|
309
|
+
actual: {
|
|
310
|
+
status: result.status,
|
|
311
|
+
saved: result.saved === true,
|
|
312
|
+
registryRevision: result.registryRevision ?? null,
|
|
313
|
+
generation: result.generation ?? null,
|
|
314
|
+
incarnation: result.incarnation ?? null,
|
|
315
|
+
},
|
|
316
|
+
error: mcpDiagnostic(cause, { code: 'owner-changed' }),
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return sanitizeMcpPayload(result)
|
|
321
|
+
} finally {
|
|
322
|
+
first.lease[Symbol.dispose]()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
mutate: (sessionId, request) => mutate(sessionId, request, false),
|
|
327
|
+
batch: (sessionId, request) => mutate(sessionId, request, true),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Every endpoint the gateway resolves through this package's ONE host-face
|
|
333
|
+
* contribution, in contribution order. A live read says nothing about write
|
|
334
|
+
* availability, so liveness is always checked across this whole set.
|
|
335
|
+
*/
|
|
336
|
+
export const CAPABILITY_ENDPOINTS = [
|
|
337
|
+
...CAPABILITY_READ_METHODS.map(method => `${CAPABILITY_READ_NAMESPACE}/${method}`),
|
|
338
|
+
...CAPABILITY_MUTATION_METHODS.map(method => `${CAPABILITY_MUTATION_NAMESPACE}/${method}`),
|
|
339
|
+
]
|
|
340
|
+
|
|
188
341
|
/**
|
|
189
342
|
* The hand-written typert contribution (S27: empty schemas/model, src-json
|
|
190
343
|
* codecs, no zod, no generated artifacts). Parameters and results are plain
|
|
191
344
|
* JSON; the envelope validates the argument name only.
|
|
345
|
+
*
|
|
346
|
+
* ALL five read and two mutation invocations live in ONE contribution, because
|
|
347
|
+
* the registry keys exactly one record per package+face and rejects any second
|
|
348
|
+
* registration while that record is live: two separate `weir-harness` host-face
|
|
349
|
+
* contributions could never overlap generations, and the face the mutation half
|
|
350
|
+
* used to declare (`host-mutations`) is not a face the registry accepts at all —
|
|
351
|
+
* register() threw and apply() swallowed it into a warning, so the write channel
|
|
352
|
+
* was silently dead. One atomic batch also means recovery restores every
|
|
353
|
+
* endpoint or none.
|
|
192
354
|
*/
|
|
193
|
-
export function
|
|
355
|
+
export function capabilityContribution() {
|
|
194
356
|
return {
|
|
195
|
-
// The registry's validatePackage requires both (missing = silently dead registration): it validates the package name and keys the package record by face.
|
|
357
|
+
// The registry's validatePackage requires both (missing = silently dead registration): it validates the package name, rejects any face except host/client, and keys the package record by face.
|
|
196
358
|
package: 'weir-harness',
|
|
197
359
|
face: 'host',
|
|
198
360
|
schemas: [],
|
|
199
361
|
model: { services: [], events: [], objects: [] },
|
|
200
|
-
invocations:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
362
|
+
invocations: [
|
|
363
|
+
...CAPABILITY_READ_METHODS.map(method => ({
|
|
364
|
+
id: `weir-harness.${CAPABILITY_READ_NAMESPACE}.${method}`,
|
|
365
|
+
service: CAPABILITY_READ_SERVICE_KEY,
|
|
366
|
+
namespace: CAPABILITY_READ_NAMESPACE,
|
|
367
|
+
method,
|
|
368
|
+
invocation: { kind: 'direct' },
|
|
369
|
+
parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }],
|
|
370
|
+
result: { mode: 'src-json' },
|
|
371
|
+
})),
|
|
372
|
+
...CAPABILITY_MUTATION_METHODS.map(method => ({
|
|
373
|
+
id: `weir-harness.${CAPABILITY_MUTATION_NAMESPACE}.${method}`,
|
|
374
|
+
service: CAPABILITY_MUTATION_SERVICE_KEY,
|
|
375
|
+
namespace: CAPABILITY_MUTATION_NAMESPACE,
|
|
376
|
+
method,
|
|
377
|
+
invocation: { kind: 'direct' },
|
|
378
|
+
parameters: [
|
|
379
|
+
{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } },
|
|
380
|
+
{ name: 'request', wire: 'request', source: 'json', codec: { mode: 'src-json' } },
|
|
381
|
+
],
|
|
382
|
+
result: { mode: 'src-json' },
|
|
383
|
+
})),
|
|
384
|
+
],
|
|
209
385
|
}
|
|
210
386
|
}
|
|
211
387
|
|
|
@@ -222,6 +398,20 @@ export function capabilityReadContribution() {
|
|
|
222
398
|
* gateway dispatch resolves through it as well, so the loader must order this
|
|
223
399
|
* row after the typert provider — including a composition where a user-layer
|
|
224
400
|
* patch injects typert after the bundle rows (S27).
|
|
401
|
+
*
|
|
402
|
+
* Registration lifecycle (S27.5, gateway/definition-unavailable): a fiber
|
|
403
|
+
* reload runs apply() again while the previous incarnation's effects are still
|
|
404
|
+
* unwinding, so BOTH the service provision and the contribution registration can
|
|
405
|
+
* lose that race, and the old incarnation then withdraws what the new one
|
|
406
|
+
* skipped. Because the gateway refuses a withdrawn strict definition before it
|
|
407
|
+
* ever reaches a service method (hasSeen + no live definition), the recovery
|
|
408
|
+
* must live in this plugin's own fiber: it subscribes to `typert.local` changes
|
|
409
|
+
* and re-attempts provision+registration on a coalesced microtask while the
|
|
410
|
+
* endpoints are incomplete. Every one of those facts is per-fiber state: a
|
|
411
|
+
* pending retry, the disposal latch and the warning latch belong to THIS
|
|
412
|
+
* generation, so an old generation can never consume the survivor's retry, and
|
|
413
|
+
* a disposed fiber cannot resurrect the endpoints (Cordis rejects its effect
|
|
414
|
+
* creation with INACTIVE_EFFECT — the final barrier, never a retry loop).
|
|
225
415
|
*/
|
|
226
416
|
export function apply(ctx) {
|
|
227
417
|
const warn = text => ctx.logger?.warn?.(text)
|
|
@@ -236,46 +426,107 @@ export function apply(ctx) {
|
|
|
236
426
|
warn('weir capability remote: typert service is unavailable in this composition; capability reads stay unavailable on the client')
|
|
237
427
|
return
|
|
238
428
|
}
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const endpointLive =
|
|
245
|
-
try { return Boolean(typert.local?.get?.(
|
|
429
|
+
// Per-fiber lifecycle state. NEVER module-global: the previous incarnation's
|
|
430
|
+
// listener and this one can both observe the same withdrawal.
|
|
431
|
+
let disposed = false
|
|
432
|
+
let retryQueued = false
|
|
433
|
+
let registrationWarned = false
|
|
434
|
+
const endpointLive = endpoint => {
|
|
435
|
+
try { return Boolean(typert.local?.get?.(endpoint)) } catch { return false }
|
|
246
436
|
}
|
|
247
|
-
const
|
|
248
|
-
|
|
437
|
+
const endpointsComplete = () => CAPABILITY_ENDPOINTS.every(endpointLive)
|
|
438
|
+
let readService
|
|
439
|
+
let mutationService
|
|
440
|
+
// Provide one service unless another incarnation still holds the host-root
|
|
441
|
+
// key. A host-root provide lives on the ROOT fiber (S27: the gateway resolves
|
|
442
|
+
// ctx.get from the host root), so the first incarnation's receiver survives
|
|
443
|
+
// every reload; replacing it is neither possible nor needed. A lost race is
|
|
444
|
+
// not fatal — the retry below keeps every endpoint self-healing.
|
|
445
|
+
const ensureService = (key, service) => {
|
|
249
446
|
try {
|
|
250
|
-
|
|
447
|
+
const current = typeof ctx.get === 'function' ? ctx.get(key) : undefined
|
|
448
|
+
if (current === service) return true
|
|
449
|
+
if (current !== undefined) return false
|
|
450
|
+
ctx.root.reflect.provide(key, service)
|
|
251
451
|
return true
|
|
452
|
+
} catch { return false }
|
|
453
|
+
}
|
|
454
|
+
const ensureServices = () => {
|
|
455
|
+
const read = ensureService(CAPABILITY_READ_SERVICE_KEY, readService)
|
|
456
|
+
const mutation = ensureService(CAPABILITY_MUTATION_SERVICE_KEY, mutationService)
|
|
457
|
+
return read && mutation
|
|
458
|
+
}
|
|
459
|
+
// Exactly ONE register attempt per call: the registry rejects the whole batch
|
|
460
|
+
// or commits all of it, so looping could only repeat the same rejection.
|
|
461
|
+
const ensureContribution = () => {
|
|
462
|
+
if (endpointsComplete()) return true
|
|
463
|
+
try {
|
|
464
|
+
typert.register(capabilityContribution())
|
|
252
465
|
} catch (cause) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
466
|
+
if (!endpointsComplete()) {
|
|
467
|
+
// A disposed fiber's rejection is terminal (the survivor owns the retry).
|
|
468
|
+
if (cause?.code === 'INACTIVE_EFFECT') {
|
|
469
|
+
disposed = true
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
if (!registrationWarned) {
|
|
473
|
+
registrationWarned = true
|
|
474
|
+
warn(`weir capability remote: registration failed (${cause instanceof Error ? cause.message : String(cause)}); capability endpoints stay unavailable on the client`)
|
|
475
|
+
}
|
|
476
|
+
return false
|
|
477
|
+
}
|
|
258
478
|
}
|
|
479
|
+
return endpointsComplete()
|
|
259
480
|
}
|
|
481
|
+
const ensureRegistered = () => ensureContribution()
|
|
482
|
+
// Coalesce a burst of registry changes into one microtask. Waiting a turn also
|
|
483
|
+
// lets the disposing incarnation finish synchronously releasing its service
|
|
484
|
+
// keys, which a synchronous retry inside the change callback would still see.
|
|
485
|
+
const queueRetry = () => {
|
|
486
|
+
if (disposed || retryQueued) return
|
|
487
|
+
retryQueued = true
|
|
488
|
+
queueMicrotask(() => {
|
|
489
|
+
retryQueued = false
|
|
490
|
+
if (disposed || endpointsComplete()) return
|
|
491
|
+
ensureServices()
|
|
492
|
+
ensureContribution()
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
// Latch disposal as early as the fiber allows, before a queued retry can run
|
|
496
|
+
// against a fiber Cordis has already detached. A fiber that is already gone
|
|
497
|
+
// needs no latch: its next register() is rejected with INACTIVE_EFFECT.
|
|
498
|
+
try {
|
|
499
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => () => { disposed = true })
|
|
500
|
+
} catch { /* detached fiber: the effect rejection is the barrier */ }
|
|
260
501
|
try {
|
|
261
502
|
const audit = createAudit(ctx)
|
|
262
503
|
const onDegraded = facts => {
|
|
263
504
|
warn(`weir capability read degraded: method=${facts.method} session=${facts.sessionId} preset=${facts.presetId} header=${facts.headerPreset} source=${facts.source} warnings=${facts.warnings.join(';')} provider=${facts.providerReason ?? '-'}: ${facts.providerError ?? '-'}`)
|
|
264
505
|
audit({ id: facts.sessionId, header: { cwd: facts.cwd } }, 'capability-read', facts)
|
|
265
506
|
}
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
})
|
|
274
|
-
Object.freeze(
|
|
275
|
-
ctx.root.reflect.provide(CAPABILITY_READ_SERVICE_KEY, service)
|
|
276
|
-
ensureRegistered()
|
|
507
|
+
// Per-session owner resolution reads the host agents registry and the
|
|
508
|
+
// preset registry lazily (both host-root services; no mount-time inject).
|
|
509
|
+
const runtime = { agents: () => ctx.get?.('agents'), agentPresets: () => ctx.get?.('agentPresets') }
|
|
510
|
+
readService = createCapabilityReadService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), onDegraded, ...runtime })
|
|
511
|
+
readService.typertRemote = Object.freeze({ service: readService, serviceKey: CAPABILITY_READ_SERVICE_KEY, namespace: CAPABILITY_READ_NAMESPACE })
|
|
512
|
+
Object.freeze(readService)
|
|
513
|
+
mutationService = createCapabilityMutationService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), ...runtime })
|
|
514
|
+
mutationService.typertRemote = Object.freeze({ service: mutationService, serviceKey: CAPABILITY_MUTATION_SERVICE_KEY, namespace: CAPABILITY_MUTATION_NAMESPACE })
|
|
515
|
+
Object.freeze(mutationService)
|
|
277
516
|
} catch (cause) {
|
|
278
517
|
warn(`weir capability remote: setup failed (${cause instanceof Error ? cause.message : String(cause)}); capability reads stay unavailable on the client`)
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
// Provision BEFORE registering: the gateway resolves the service key of a
|
|
521
|
+
// live definition, so an endpoint must never outlive its receiver.
|
|
522
|
+
ensureServices()
|
|
523
|
+
ensureContribution()
|
|
524
|
+
// Independent per-fiber subscription: hosts without it keep the older
|
|
525
|
+
// self-heal-on-call behaviour (each service call still checks the endpoints).
|
|
526
|
+
try {
|
|
527
|
+
typert.local?.subscribe?.(() => { if (!endpointsComplete()) queueRetry() })
|
|
528
|
+
} catch (cause) {
|
|
529
|
+
warn(`weir capability remote: registration watch failed (${cause instanceof Error ? cause.message : String(cause)}); the capability channel only self-heals on the next call`)
|
|
279
530
|
}
|
|
280
531
|
}
|
|
281
532
|
|
|
@@ -154,7 +154,7 @@ export function resolveSkillNamesForSave(names, candidates, appliedSkills) {
|
|
|
154
154
|
return { identities, missing, ambiguous }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [] }) {
|
|
157
|
+
export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [], mcpIncarnations }) {
|
|
158
158
|
// A cleared marker is an explicit removal (9.4): it behaves as ABSENT —
|
|
159
159
|
// clearing is never an explicit empty set, which stays a savable choice.
|
|
160
160
|
const cleared = defaultsRecord && defaultsRecord.kind === 'ok'
|
|
@@ -169,13 +169,14 @@ export function resolveInitialSelection({ defaultsRecord, builtinIdentities = []
|
|
|
169
169
|
selection: {
|
|
170
170
|
skills: Array.isArray(payload.skills) ? structuredClone(payload.skills) : [],
|
|
171
171
|
mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers).slice() : [],
|
|
172
|
+
...((mcpIncarnations ?? payload.mcpIncarnations) ? { mcpIncarnations: structuredClone(mcpIncarnations ?? payload.mcpIncarnations) } : {}),
|
|
172
173
|
},
|
|
173
174
|
missing: Array.isArray(payload.unresolvedRefs) ? payload.unresolvedRefs.slice() : [],
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
return {
|
|
177
178
|
source: 'builtin-baseline',
|
|
178
|
-
selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice() },
|
|
179
|
+
selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice(), ...(mcpIncarnations ? { mcpIncarnations: structuredClone(mcpIncarnations) } : {}) },
|
|
179
180
|
missing: [],
|
|
180
181
|
}
|
|
181
182
|
}
|