weir-harness 1.2.2 → 2.0.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.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- 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 +1066 -0
- package/src/file-lease/index.js +30 -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 +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/matcher.js +27 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/lsp/tools.js +137 -27
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +29 -28
- package/src/shared/audit.js +6 -1
- 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/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
package/src/edit-lock/remote.js
DELETED
|
@@ -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
|
-
}
|