weir-harness 1.2.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,362 +0,0 @@
1
- import { createHash, randomUUID } from 'node:crypto'
2
- import { lstatSync, realpathSync, unlinkSync } from 'node:fs'
3
- import { createConnection, createServer } from 'node:net'
4
- import { tmpdir } from 'node:os'
5
- import { join } from 'node:path'
6
- import { asBindingFailure, bindingGuidance, bindingRefusal } from './binding-guidance.js'
7
- import { createEditLockPeer } from './peer.js'
8
- import { serveEditLockPeer } from './peer-transport.js'
9
- import { createEditLockPeerClient } from './peer-client.js'
10
-
11
- /** Deterministic per-user endpoint for one canonical authority directory. On
12
- * POSIX hosts the OS temp directory is user-private and the endpoint is a
13
- * domain-socket file; on Windows it is a named pipe (a kernel object with no
14
- * filesystem node, so there is no stale-node sweep and no 100-byte path
15
- * budget — only the 256-char pipe-name limit) whose name carries no
16
- * backslashes beyond the \\.\pipe\ prefix (verified on Node v24). Any
17
- * same-user process that can reach the endpoint is inside the cooperative
18
- * trust boundary, not attested.
19
- * @param {string} directory @param {string} [platform] */
20
- export function endpointFor(directory, platform = process.platform) {
21
- const authority = realpathSync.native(directory)
22
- const hash = createHash('sha256').update(authority).digest('hex').slice(0, 24)
23
- if (platform === 'win32') return `\\\\.\\pipe\\weir-edit-lock-${hash}`
24
- const path = join(tmpdir(), `weir-edit-lock-${hash}.sock`)
25
- if (Buffer.byteLength(path) > 100) throw new Error(`edit lock endpoint path too long: ${path}`)
26
- return path
27
- }
28
-
29
- /** True for Windows named-pipe endpoints: no filesystem node exists, so the
30
- * stale-socket sweep and the close-time unlink are POSIX-only concerns.
31
- * @param {string} endpoint */
32
- const namedPipe = endpoint => endpoint.startsWith('\\\\.\\pipe\\') || endpoint.startsWith('\\\\?\\pipe\\')
33
-
34
- /** Channel-open arbitration for one endpoint (duplicate-binding recovery,
35
- * design D1/D2/D4). One sessionId has at most one registered channel at any
36
- * moment:
37
- *
38
- * - a prior binding whose socket is PROVEN dead (synchronous state bits only:
39
- * `destroyed`, `closed` or `errored` — never an active probe) is reclaimed
40
- * through the SAME disconnect path a processed EOF takes (revoke + forget;
41
- * no lock is ever released), then the new open proceeds;
42
- * - a live-looking same-process holder may also be reclaimed when its nonce
43
- * is host-attested stale; serving, cross-process and unknown holders refuse;
44
- * - with no prior channel binding, a refusal from `lifecycle.start` itself
45
- * (a local agent or another mount generation holds the session) passes
46
- * through: local runtime identities are adjudicated only by the lifecycle.
47
- *
48
- * Opens of one sessionId are serialized through a per-session promise queue,
49
- * because the reclaim-check-register sequence awaits; different sessionIds
50
- * stay fully concurrent. Exported for the unit tests; production wiring is
51
- * the endpoint below.
52
- * @param {(sessionId: string, nonce: string) => 'serving'|'stale'|'unknown'} [attest] */
53
- export function createChannelBindingArbitration(attest = () => 'unknown') {
54
- /** @typedef {{destroyed?: boolean, closed?: boolean, errored?: unknown}} ChannelSocket */
55
- /** @typedef {{disconnect: () => Promise<unknown>}} ChannelPeer */
56
- /** @type {Map<string, {socket: ChannelSocket, peer: ChannelPeer, nonce?: string, pid?: number}>} */
57
- const bound = new Map()
58
- /** @type {Map<string, Promise<unknown>>} */
59
- const opening = new Map()
60
- /** @param {string} sessionId @param {{pid?: number, nonce?: string}} holder */
61
- function verdict(sessionId, holder) {
62
- if (holder.pid !== process.pid || !holder.nonce) return 'unknown'
63
- try { return attest(sessionId, holder.nonce) } catch { return 'unknown' }
64
- }
65
- /** @param {string} sessionId
66
- * @param {ChannelSocket} socket the NEW connection's socket
67
- * @param {() => Promise<{peer: ChannelPeer}>} bind establish the channel
68
- * (create the peer and start the lifecycle); its result is returned verbatim
69
- * @param {{nonce?: string, pid?: number}} [token] additive optional client identity
70
- * @param {() => void} [validate]
71
- * @returns {Promise<any>} */
72
- function open(sessionId, socket, bind, token = {}, validate = () => {}) {
73
- return enqueue(sessionId, async () => {
74
- validate()
75
- await reclaim(sessionId)
76
- validate()
77
- const established = await bind()
78
- bound.set(sessionId, { socket, peer: established.peer, nonce: token.nonce, pid: token.pid })
79
- return established
80
- })
81
- }
82
- /** Local and remote startup share this queue; the trusted callback rechecks
83
- * exact claimant identity after the durable revoke barrier.
84
- * @param {string} sessionId @param {(reclaimed: boolean) => Promise<any>} bind @param {() => void} [validate] */
85
- function openLocal(sessionId, bind, validate = () => {}) {
86
- return enqueue(sessionId, async () => { validate(); const reclaimed = await reclaim(sessionId); validate(); return bind(reclaimed) })
87
- }
88
- /** @param {string} sessionId @param {() => Promise<any>} step */
89
- function enqueue(sessionId, step) {
90
- const queued = opening.get(sessionId) ?? Promise.resolve()
91
- const run = queued.then(step, step)
92
- opening.set(sessionId, run)
93
- const settled = () => { if (opening.get(sessionId) === run) opening.delete(sessionId) }
94
- run.then(settled, settled)
95
- return run
96
- }
97
- /** @param {string} sessionId */
98
- async function reclaim(sessionId) {
99
- const prior = bound.get(sessionId)
100
- if (prior) {
101
- const dead = prior.socket.destroyed === true || prior.socket.closed === true || prior.socket.errored != null
102
- const attestation = dead ? 'unknown' : verdict(sessionId, prior)
103
- const refusal = () => bindingRefusal(prior.pid, attestation)
104
- if (!dead && attestation !== 'stale') throw refusal()
105
- try { await prior.peer.disconnect() } catch {
106
- // Reclaim outcome unknown: stay fail-closed and KEEP the mapping, so
107
- // the next open retries the reclaim instead of wedging behind a
108
- // lifecycle-level refusal.
109
- throw bindingRefusal(prior.pid, attestation, true)
110
- }
111
- if (bound.get(sessionId) === prior) bound.delete(sessionId)
112
- }
113
- return prior !== undefined
114
- }
115
- /** Index hygiene on transport close: drop the entry only while it still
116
- * names THIS connection's socket — a reclaimed binding must never be removed
117
- * by the late close of the dead socket it replaced. Await the same cached
118
- * disconnect result as the transport before forgetting its mapping.
119
- * @param {string} sessionId @param {ChannelSocket} socket */
120
- async function release(sessionId, socket) {
121
- const prior = bound.get(sessionId)
122
- if (!prior || prior.socket !== socket) return
123
- // EOF and attested reclaim share the durable disconnect result. A failed
124
- // EOF must not erase the retryable mapping either.
125
- try { await prior.peer.disconnect() } catch { return }
126
- if (bound.get(sessionId) === prior) bound.delete(sessionId)
127
- }
128
- return Object.freeze({ open, openLocal, release,
129
- bindings() {
130
- return [...bound].map(([sessionId, holder]) => {
131
- const attestation = verdict(sessionId, holder)
132
- return { sessionId, present: true, holderPid: holder.pid ?? null, attestable: attestation !== 'unknown', attestation }
133
- })
134
- },
135
- })
136
- }
137
-
138
- /** Publisher side: one session channel per connection. The first frame must be
139
- * `open {sessionId}`; the channel agent is fixed afterwards and EOF revokes it.
140
- * Call only while holding the publisher reservation (stale socket removal).
141
- * @param {ReturnType<typeof import('./lifecycle.js').createEditLockLifecycle>} lifecycle
142
- * @param {string} endpoint
143
- * @param {WeakMap<object, (event: unknown) => void>} sinks remote agent → notice sink
144
- * @param {(sessionId: string, nonce: string) => 'serving'|'stale'|'unknown'} [attest]
145
- * @param {(agent: any, requireProof: boolean) => boolean} [isCurrentLocal] */
146
- export async function serveEditLockEndpoint(lifecycle, endpoint, sinks, attest, isCurrentLocal) {
147
- if (!namedPipe(endpoint)) {
148
- try {
149
- if (!lstatSync(endpoint).isSocket()) throw new Error(`unexpected node at edit lock endpoint ${endpoint}`)
150
- unlinkSync(endpoint)
151
- } catch (error) { if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error }
152
- }
153
- /** @type {Set<{close: () => Promise<unknown>}>} */
154
- const channels = new Set()
155
- let closed = false
156
- const arbitration = createChannelBindingArbitration(attest)
157
- const server = createServer(socket => {
158
- if (closed) { socket.destroy(); return }
159
- /** @type {ReturnType<typeof createEditLockPeer> | undefined} */
160
- let inner
161
- /** @type {string | undefined} */
162
- let openedSessionId
163
- const router = {
164
- /** @param {any} message */
165
- async receive(message) {
166
- if (inner) return inner.receive(message)
167
- const sessionId = message?.request?.sessionId
168
- if (message?.kind !== 'open' || typeof sessionId !== 'string' || !sessionId || sessionId.length > 256) throw new Error('channel must open a session first')
169
- openedSessionId = sessionId
170
- try {
171
- const opened = await arbitration.open(sessionId, socket, async () => {
172
- const agent = Object.freeze({ id: sessionId, remote: true })
173
- sinks.set(agent, event => transport.notify(event))
174
- inner = createEditLockPeer(lifecycle, agent)
175
- // A retry open is a trusted driving ingress (change
176
- // edit-lock-binding-transfer): when the ordinary start is refused by
177
- // a duplicate LOCAL holder, the transfer admission re-adjudicates the
178
- // holder's idleness publisher-side. start() runs first and refuses
179
- // without side effects, so a non-transferable refusal keeps its
180
- // original classification and is evaluated exactly once per caller.
181
- if (message.request.retry === true && typeof lifecycle.transferStart === 'function') {
182
- try {
183
- const state = await lifecycle.start(agent)
184
- return { peer: inner, state }
185
- } catch (error) {
186
- if (/** @type {any} */ (error)?.bindingFailure?.code !== 'binding-duplicate') throw error
187
- const state = await lifecycle.transferStart(agent)
188
- return { peer: inner, state }
189
- }
190
- }
191
- const state = await lifecycle.start(agent)
192
- return { peer: inner, state }
193
- }, { nonce: typeof message.request.nonce === 'string' && message.request.nonce.length <= 256 ? message.request.nonce : undefined,
194
- pid: Number.isSafeInteger(message.request.pid) && message.request.pid > 0 ? message.request.pid : undefined },
195
- () => { if (message.request.retry === true) lifecycle.assertStartable({ id: sessionId, remote: true }) })
196
- return { state: opened.state }
197
- } catch (error) {
198
- const failure = /** @type {any} */ (error)?.bindingFailure
199
- if (message.request.bindingFailures === true && failure?.code === 'binding-duplicate') return { state: 'unavailable', bindingFailure: failure, reason: String(/** @type {any} */ (error).message) }
200
- throw error
201
- }
202
- },
203
- disconnect() { return inner ? inner.disconnect() : Promise.resolve() },
204
- }
205
- const transport = serveEditLockPeer(socket, router)
206
- channels.add(transport)
207
- socket.once('close', () => {
208
- channels.delete(transport)
209
- if (openedSessionId !== undefined) arbitration.release(openedSessionId, socket)
210
- })
211
- })
212
- await new Promise((resolve, reject) => {
213
- server.once('error', reject)
214
- server.listen(endpoint, () => { server.off('error', reject); resolve(undefined) })
215
- })
216
- return Object.freeze({
217
- bindings: arbitration.bindings,
218
- /** @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
219
- * transfer: the claimant arrives through a driving ingress whose registry
220
- * identity is the defect being repaired — the lifecycle transfer admission
221
- * decides, and the currency proof relaxes accordingly (isCurrentLocal). */
222
- startLocal(agent, retry = false, transfer = false) {
223
- return arbitration.openLocal(agent.id, async reclaimed => {
224
- const current = () => {
225
- try { return !closed && (isCurrentLocal ? isCurrentLocal(agent, reclaimed || retry, transfer) : !(reclaimed || retry)) } catch { return false }
226
- }
227
- if (!current()) throw new Error('local edit binding claimant is no longer current')
228
- const state = transfer && typeof lifecycle.transferStart === 'function' ? await lifecycle.transferStart(agent) : await lifecycle.start(agent)
229
- if (!current()) {
230
- // A stopped entry still has subtractive cleanup authority. Forget it
231
- // only after durable revocation; never dispose (which releases locks).
232
- await lifecycle.forget(agent)
233
- throw new Error('local edit binding claimant is no longer current')
234
- }
235
- return state
236
- }, () => { if (retry) lifecycle.assertStartable(agent) })
237
- },
238
- /** Seal admission, then revoke every channel; await before lifecycle.close. */
239
- async close() {
240
- closed = true
241
- await new Promise(resolve => server.close(() => resolve(undefined)))
242
- const results = await Promise.allSettled([...channels].map(channel => channel.close()))
243
- if (!namedPipe(endpoint)) {
244
- try { unlinkSync(endpoint) } catch { /* already gone */ }
245
- }
246
- const errors = results.filter(result => result.status === 'rejected').map(result => /** @type {any} */ (result).reason)
247
- if (errors.length) throw new AggregateError(errors, 'edit lock endpoint revocation failed')
248
- },
249
- })
250
- }
251
-
252
- /** Client side for a host whose publisher reservation is held elsewhere. Each
253
- * agent gets its own session channel; a closed channel is never silently
254
- * replaced mid-call. Outcomes of interrupted calls are UNKNOWN by contract.
255
- * @param {string} endpoint
256
- * @param {{onNotice: (agent: object, event: any) => void, unreachableHint?: string, onChannelOpen?: (nonce: string, agent: object, sessionId: string) => (() => void)}} hooks */
257
- export function createRemoteEditLockDomain(endpoint, hooks) {
258
- /** @typedef {{client: ReturnType<typeof createEditLockPeerClient>, state: string, closed: boolean, ready: Promise<void>}} Channel */
259
- /** @type {WeakMap<object, Channel>} */
260
- const channels = new WeakMap()
261
- /** @type {Set<Channel>} */
262
- const all = new Set()
263
- const never = new AbortController().signal
264
- let closed = false
265
- /** Failure classes are split by WHERE the open failed, never by guessing at
266
- * message text (design D3): a transport that never came up — or an open
267
- * request that never got an answer — is an unreachable publisher and carries
268
- * the reservation hint; an open the publisher answered and refused is a live
269
- * publisher rejecting the binding, surfaced with its own guidance after the
270
- * duplicate-binding retry budget runs out. Only that answered refusal is
271
- * retried: the publisher may not yet have processed (or even seen) the
272
- * earlier channel's death. Never a call.
273
- * @param {any} agent @param {boolean} [retry] @returns {Promise<Channel>} */
274
- async function channelFor(agent, retry = false) {
275
- for (let attempt = 0; ; attempt++) {
276
- try { return await openChannel(agent, retry) } catch (error) {
277
- if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
278
- // The retry budget classifies on the STRUCTURED refusal field, never on
279
- // the display text (change edit-lock-deepening, design D2): only an
280
- // answered, publisher-classified duplicate binding is retried — the
281
- // publisher may not yet have processed the earlier channel's death.
282
- if (!retry && /** @type {any} */ (error)?.bindingFailure?.code === 'binding-duplicate' && attempt < 20) {
283
- await new Promise(resolve => setTimeout(resolve, 50))
284
- continue
285
- }
286
- const reason = String(/** @type {any} */ (error)?.message ?? error)
287
- const guidance = bindingGuidance(agent.id, /** @type {any} */ (error)?.bindingFailure)
288
- throw Object.assign(new Error(`edit lock duplicate session binding: ${guidance} (publisher answered: ${reason})`), { bindingFailure: /** @type {any} */ (error)?.bindingFailure })
289
- }
290
- }
291
- }
292
- /** @param {any} agent @param {boolean} retry @returns {Promise<Channel>} */
293
- async function openChannel(agent, retry) {
294
- if (closed) throw new Error('edit lock client closed')
295
- const existing = channels.get(agent)
296
- if (existing && !existing.closed) { await existing.ready; return existing }
297
- const nonce = randomUUID()
298
- const unregister = hooks.onChannelOpen?.(nonce, agent, agent.id) ?? (() => {})
299
- let socket
300
- try { socket = createConnection(endpoint) } catch (error) { unregister(); throw error }
301
- /** @type {Channel} */
302
- const channel = /** @type {any} */ ({ state: 'starting', closed: false })
303
- channel.client = createEditLockPeerClient(socket, {
304
- onEvent: event => hooks.onNotice(agent, event),
305
- onClose: () => { unregister(); channel.closed = true; channel.state = 'stopped'; all.delete(channel) },
306
- })
307
- channels.set(agent, channel)
308
- all.add(channel)
309
- /** The publisher genuinely did not answer. @param {unknown} cause */
310
- const unreachable = cause => new Error(`edit lock publisher unreachable at ${endpoint}: ${/** @type {any} */ (cause)?.message ?? cause}${hooks.unreachableHint ? `. ${hooks.unreachableHint}` : ''}`)
311
- channel.ready = (async () => {
312
- try {
313
- await new Promise((resolve, reject) => {
314
- socket.once('connect', resolve)
315
- socket.once('error', reject)
316
- })
317
- } catch (error) {
318
- // The transport never came up: the publisher process is not answering.
319
- throw unreachable(error)
320
- }
321
- let opened
322
- try {
323
- opened = await channel.client.request('open', 'open', { sessionId: agent.id, nonce, pid: process.pid, bindingFailures: true, retry }, never)
324
- // The answered refusal's structured field is validated on receipt;
325
- // only the exact minted shape becomes decision input.
326
- const refused = opened?.state === 'unavailable' ? asBindingFailure(opened.bindingFailure) : undefined
327
- if (refused) throw Object.assign(new Error(opened.reason), { bindingFailure: refused })
328
- } catch (error) {
329
- const message = String(/** @type {any} */ (error)?.message ?? error)
330
- // No answer ever arrived — the channel died mid-open or the request
331
- // was never dispatched: the same failure class as a connect error.
332
- if (message.startsWith('edit publication outcome UNKNOWN:') || message.startsWith('edit peer unavailable')) throw unreachable(error)
333
- // The publisher answered and refused the open. channelFor owns the
334
- // retry budget and the final message for answered refusals.
335
- throw Object.assign(error instanceof Error ? error : new Error(message), { answeredRefusal: true })
336
- }
337
- if (!channel.closed) channel.state = opened.state
338
- })()
339
- try { await channel.ready } catch (error) {
340
- channel.client.close()
341
- throw error
342
- }
343
- return channel
344
- }
345
- /** @param {object} agent @param {string} kind @param {unknown} [request] @param {AbortSignal} [signal] @param {string} [callId] */
346
- async function call(agent, kind, request, signal, callId) {
347
- const channel = await channelFor(agent)
348
- return channel.client.request(kind, callId ?? `${kind}:${randomUUID()}`, request ?? {}, signal ?? never)
349
- }
350
- return Object.freeze({
351
- channelFor,
352
- call,
353
- /** @param {object} agent */
354
- state(agent) { const channel = channels.get(agent); return channel && !channel.closed ? channel.state : 'stopped' },
355
- /** @param {object} agent @param {string} state */
356
- setState(agent, state) { const channel = channels.get(agent); if (channel && !channel.closed) channel.state = state },
357
- /** Conservative local revocation: the publisher revokes on EOF.
358
- * @param {object} agent */
359
- drop(agent) { channels.get(agent)?.client.close() },
360
- close() { closed = true; for (const channel of [...all]) channel.client.close() },
361
- })
362
- }
@@ -1,42 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { isAbsolute } from 'node:path'
3
- import { canonicalRequestData } from './request-data.js'
4
- import { validateBinding } from './operation-history.js'
5
-
6
- /** @param {string} value */
7
- const digest = value => createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex')
8
-
9
- /** Compute historical binding only; never grants publication authority.
10
- * Caller supplies trusted, fully resolved policy and observed target, not model
11
- * assertions. Policy authorization and topology checks remain manager duties.
12
- * @param {{tool: 'write'|'hash_edit'|'lsp_rename', filePath: string, cwd: string, args: unknown, content: string,
13
- * effectivePolicy: unknown, target: import('./operation-history.js').Target}} input
14
- * @returns {import('./operation-history.js').Binding} */
15
- export function bindRequest(input) {
16
- // Validate before property reads/copying, so ordinary getters never execute.
17
- const data = JSON.parse(canonicalRequestData(input))
18
- if (typeof data.content !== 'string') throw new Error('invalid content')
19
- if (!['write', 'hash_edit', 'lsp_rename'].includes(data.tool)) throw new Error('invalid tool')
20
- if (typeof data.filePath !== 'string' || !data.filePath || data.filePath.includes('\0') ||
21
- typeof data.cwd !== 'string' || !isAbsolute(data.cwd) || data.cwd.includes('\0')) throw new Error('invalid request path')
22
- if (!data.target || !['create', 'update'].includes(data.target.kind)) throw new Error('invalid target')
23
- if (data.target.kind === 'create' && data.tool !== 'write') throw new Error('creation requires write')
24
- if (!data.effectivePolicy || typeof data.effectivePolicy !== 'object' || Array.isArray(data.effectivePolicy)) throw new Error('invalid effective policy')
25
- const argsDigest = digest(canonicalRequestData(data.args))
26
- const payloadDigest = digest(data.content)
27
- const requestDigest = digest(canonicalRequestData({ version: 1, tool: data.tool, method: 'writeText',
28
- filePath: data.filePath, cwd: data.cwd, expected: data.target.policy, effectivePolicy: data.effectivePolicy,
29
- argsDigest, payloadDigest, target: data.target }))
30
- /** @param {any} value */
31
- function freeze(value) {
32
- if (value && typeof value === 'object') {
33
- for (const child of Object.values(value)) freeze(child)
34
- Object.freeze(value)
35
- }
36
- return value
37
- }
38
- const binding = { tool: data.tool, filePath: data.filePath, cwd: data.cwd,
39
- argsDigest, payloadDigest, requestDigest, target: data.target }
40
- validateBinding(binding)
41
- return freeze(binding)
42
- }
@@ -1,40 +0,0 @@
1
- /** Strict canonical JSON for trusted adapter input, before hashing or copying.
2
- * Rejects lossy representations rather than silently dropping request fields.
3
- * This is not a sandbox for hostile executable objects (including Proxies).
4
- * @param {unknown} value @returns {string} */
5
- export function canonicalRequestData(value) {
6
- const ancestors = new Set()
7
- /** @param {unknown} node @returns {string} */
8
- function encode(node) {
9
- if (node === null || typeof node === 'string' || typeof node === 'boolean') return JSON.stringify(node)
10
- if (typeof node === 'number' && Number.isFinite(node) && !Object.is(node, -0)) return JSON.stringify(node)
11
- if (typeof node !== 'object' || node === null || ancestors.has(node)) throw new Error('invalid request data')
12
- const array = Array.isArray(node)
13
- const prototype = Object.getPrototypeOf(node)
14
- if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new Error('invalid request data prototype')
15
- const descriptors = Object.getOwnPropertyDescriptors(node)
16
- const keys = Reflect.ownKeys(descriptors)
17
- for (const key of keys) {
18
- if (typeof key !== 'string') throw new Error('invalid request data symbol')
19
- const descriptor = descriptors[key]
20
- if (!('value' in descriptor) || (!descriptor.enumerable && !(array && key === 'length'))) throw new Error('invalid request data descriptor')
21
- }
22
- ancestors.add(node)
23
- try {
24
- if (array) {
25
- if (keys.length !== node.length + 1) throw new Error('invalid request data array')
26
- const items = []
27
- for (let index = 0; index < node.length; index++) {
28
- const descriptor = descriptors[String(index)]
29
- if (!descriptor) throw new Error('invalid request data sparse array')
30
- items.push(encode(descriptor.value))
31
- }
32
- return `[${items.join(',')}]`
33
- }
34
- return `{${Object.keys(descriptors).sort().map(key => `${JSON.stringify(key)}:${encode(descriptors[key].value)}`).join(',')}}`
35
- } finally {
36
- ancestors.delete(node)
37
- }
38
- }
39
- return encode(value)
40
- }