dsh-hitl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/README.zh.md +490 -0
- package/client.js +1918 -0
- package/cordis.patch.yml +17 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
- package/icon.svg +6 -0
- package/index.js +629 -0
- package/lib/fields.js +244 -0
- package/lib/pending.js +217 -0
- package/lib/protocol.js +182 -0
- package/lib/resolve.js +302 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
- package/tests/client.test.js +818 -0
- package/tests/docs.test.js +116 -0
- package/tests/fields.test.js +269 -0
- package/tests/manifest.test.js +61 -0
- package/tests/pending.test.js +240 -0
- package/tests/protocol.test.js +117 -0
- package/tests/resolve.test.js +162 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The dsh-hitl bundle patch: one host row.
|
|
2
|
+
#
|
|
3
|
+
# The row's `config.protect` list mounts HITL on tools without writing code:
|
|
4
|
+
# each entry is { tool, ...hitlOptions } and mirrors ctx.hitl.protect(tool, options).
|
|
5
|
+
# Plugins mount through the `hitl` service instead:
|
|
6
|
+
#
|
|
7
|
+
# ctx.inject(['hitl'], (ctx) => {
|
|
8
|
+
# ctx.hitl.protect('bash', { countdown: { seconds: 30, action: 'reject' } })
|
|
9
|
+
# })
|
|
10
|
+
#
|
|
11
|
+
# A patch replaces the whole `config` of a row it overrides, so keep every key
|
|
12
|
+
# this row owns when you edit it from a later layer.
|
|
13
|
+
- insert:
|
|
14
|
+
- id: hitl
|
|
15
|
+
name: 'dsh-hitl'
|
|
16
|
+
config:
|
|
17
|
+
protect: []
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="HITL">
|
|
2
|
+
<rect x="4" y="12" width="56" height="40" rx="8" fill="none" stroke="#247bbf" stroke-width="3"/>
|
|
3
|
+
<circle cx="20" cy="28" r="4" fill="#247bbf"/>
|
|
4
|
+
<path d="M32 26h20M32 34h14" stroke="#247bbf" stroke-width="3" stroke-linecap="round"/>
|
|
5
|
+
<path d="M18 46h28" stroke="#247bbf" stroke-width="3" stroke-linecap="round" opacity="0.55"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hitl host half: the human-in-the-loop gate for tool calls.
|
|
3
|
+
*
|
|
4
|
+
* Other tools and plugins mount HITL through the `hitl` service this plugin
|
|
5
|
+
* provides (`ctx.hitl.protect(...)`), or a user mounts them from this row's
|
|
6
|
+
* `config.protect` list. A mounted call is intercepted on `tools/pre-execute`,
|
|
7
|
+
* the proposal is broadcast to every connected browser, and the human's answer
|
|
8
|
+
* becomes the pre-execution decision: allow, deny with the user's own words, or
|
|
9
|
+
* deny with the revised proposal.
|
|
10
|
+
*
|
|
11
|
+
* The browser half is `client.js`; the two halves meet on this plugin's own
|
|
12
|
+
* same-origin HTTP route (a Server-Sent-Events downlink plus one JSON uplink),
|
|
13
|
+
* because a profile-installed bundle can neither add a generated Remote
|
|
14
|
+
* namespace nor extend the shipped forwarded-event allowlist. The route is
|
|
15
|
+
* closed by a per-process token injected into the served index.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_ENDPOINT, ERROR_CODES, GLOBAL_KEY, LIMITS, OUTCOMES, errorBody, frameHoldAck,
|
|
22
|
+
framePing, frameRequest, frameSettled, frameSnapshot, okBody, parseUplink, sseChunk,
|
|
23
|
+
} from './lib/protocol.js'
|
|
24
|
+
import { buildRequest, capReason, describeDecision, isRevision, revisionContext } from './lib/fields.js'
|
|
25
|
+
import { DEFAULT_HOLD_GRACE_MS, createPendingRegistry } from './lib/pending.js'
|
|
26
|
+
import { matchMount, mountApplies, normalizeMount, normalizeProtectList } from './lib/resolve.js'
|
|
27
|
+
|
|
28
|
+
/** Plugin name, as the row and diagnostics label it. */
|
|
29
|
+
export const name = 'hitl'
|
|
30
|
+
|
|
31
|
+
/** Error identities the model reads alongside a denied call. */
|
|
32
|
+
const FAILURES = {
|
|
33
|
+
rejected: { name: 'HitlRejected', code: 'HITL_REJECTED' },
|
|
34
|
+
revised: { name: 'HitlRevised', code: 'HITL_REVISED' },
|
|
35
|
+
timeout: { name: 'HitlTimeout', code: 'HITL_TIMEOUT' },
|
|
36
|
+
unavailable: { name: 'HitlUnavailable', code: 'HITL_UNAVAILABLE' },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Heartbeat period of one SSE downlink. */
|
|
40
|
+
const HEARTBEAT_MS = 15000
|
|
41
|
+
|
|
42
|
+
/** Loopback forms a decision request may arrive from. */
|
|
43
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
44
|
+
|
|
45
|
+
function normalizeEndpoint(value) {
|
|
46
|
+
if (typeof value !== 'string' || value === '') return DEFAULT_ENDPOINT
|
|
47
|
+
const trimmed = value.startsWith('/') ? value : `/${value}`
|
|
48
|
+
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveInteger(value, fallback) {
|
|
52
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Process-wide symbol holding the tokens this process has handed to pages. */
|
|
56
|
+
const TOKEN_REGISTRY = Symbol.for('dsh-hitl.decision-tokens')
|
|
57
|
+
|
|
58
|
+
/** How many previously issued tokens stay acceptable, oldest dropped first. */
|
|
59
|
+
const TOKEN_MEMORY = 8
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The decision token for this process.
|
|
63
|
+
*
|
|
64
|
+
* The token is injected into every served page, so rotating it on a plugin
|
|
65
|
+
* reload would strand every open page until it is refreshed: each page keeps
|
|
66
|
+
* presenting the token it booted with, and its event stream would never open
|
|
67
|
+
* again. The first activation in a process therefore mints the token, every
|
|
68
|
+
* later activation reuses it, and every token this process issued stays
|
|
69
|
+
* acceptable until the registry's bound drops it.
|
|
70
|
+
* @returns the token to inject and accept.
|
|
71
|
+
*/
|
|
72
|
+
function processTokens() {
|
|
73
|
+
const existing = globalThis[TOKEN_REGISTRY]
|
|
74
|
+
if (existing instanceof Set) return existing
|
|
75
|
+
const registry = new Set()
|
|
76
|
+
Object.defineProperty(globalThis, TOKEN_REGISTRY, { value: registry, enumerable: false })
|
|
77
|
+
return registry
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register the HITL host half.
|
|
82
|
+
* @param ctx - host Cordis context of this plugin row.
|
|
83
|
+
* @param config - `{ protect, endpoint, holdGraceMs }` from the row.
|
|
84
|
+
*/
|
|
85
|
+
export function apply(ctx, config = {}) {
|
|
86
|
+
const options = config === null || typeof config !== 'object' ? {} : config
|
|
87
|
+
const warn = message => {
|
|
88
|
+
const line = `dsh-hitl: ${message}`
|
|
89
|
+
try {
|
|
90
|
+
if (ctx.logger !== undefined && typeof ctx.logger.warn === 'function') ctx.logger.warn(line)
|
|
91
|
+
else console.warn(line)
|
|
92
|
+
} catch {
|
|
93
|
+
console.warn(line)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const tokens = processTokens()
|
|
98
|
+
const token = tokens.size === 0 ? randomUUID() : [...tokens][0]
|
|
99
|
+
tokens.add(token)
|
|
100
|
+
while (tokens.size > TOKEN_MEMORY) {
|
|
101
|
+
const oldest = [...tokens][0]
|
|
102
|
+
if (oldest === token) break
|
|
103
|
+
tokens.delete(oldest)
|
|
104
|
+
}
|
|
105
|
+
const endpoint = normalizeEndpoint(options.endpoint)
|
|
106
|
+
const mounts = []
|
|
107
|
+
const clients = new Set()
|
|
108
|
+
const resolvers = new Map()
|
|
109
|
+
const cleanups = new Map()
|
|
110
|
+
const revisions = new Map()
|
|
111
|
+
|
|
112
|
+
const registry = createPendingRegistry({
|
|
113
|
+
holdGraceMs: positiveInteger(options.holdGraceMs, DEFAULT_HOLD_GRACE_MS),
|
|
114
|
+
onSettle: (entry, settlement) => {
|
|
115
|
+
const cleanup = cleanups.get(entry.id)
|
|
116
|
+
if (cleanup !== undefined) {
|
|
117
|
+
cleanups.delete(entry.id)
|
|
118
|
+
try {
|
|
119
|
+
cleanup()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
warn(`abort listener cleanup failed: ${String(error)}`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
broadcast(frameSettled(entry.id, settlement.source))
|
|
125
|
+
const resolve = resolvers.get(entry.id)
|
|
126
|
+
resolvers.delete(entry.id)
|
|
127
|
+
if (resolve !== undefined) resolve(settlement)
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Re-read one request's countdown as it stands right now.
|
|
133
|
+
*
|
|
134
|
+
* The request object is built once, when the call is gated, so its
|
|
135
|
+
* `remainingMs` is the countdown's full length. A browser that receives that
|
|
136
|
+
* object later — on a reconnect snapshot, or after a client-module reload —
|
|
137
|
+
* would restart the visible countdown from the full length while the host
|
|
138
|
+
* clock kept running, and the panel would then claim time the call no longer
|
|
139
|
+
* has. Every frame therefore carries the host's own remainder.
|
|
140
|
+
*/
|
|
141
|
+
function liveRequest(request) {
|
|
142
|
+
if (request.countdown === null || request.countdown === undefined) return request
|
|
143
|
+
const remainingMs = registry.remaining(request.id)
|
|
144
|
+
if (remainingMs === null) return request
|
|
145
|
+
return { ...request, countdown: { ...request.countdown, remainingMs, held: registry.held(request.id) } }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Push one frame to every connected browser, dropping the ones that fail. */
|
|
149
|
+
function broadcast(frame) {
|
|
150
|
+
const payload = sseChunk(frame)
|
|
151
|
+
for (const client of clients) {
|
|
152
|
+
try {
|
|
153
|
+
client.res.write(payload)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
warn(`dropping a decision client: ${String(error)}`)
|
|
156
|
+
dropClient(client)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function dropClient(client) {
|
|
162
|
+
if (!clients.delete(client)) return
|
|
163
|
+
try {
|
|
164
|
+
clearInterval(client.heartbeat)
|
|
165
|
+
} catch {
|
|
166
|
+
/* the heartbeat is best-effort cleanup */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── mounting ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Mount HITL on one tool matcher.
|
|
174
|
+
*
|
|
175
|
+
* Pass the calling plugin's context as `owner` to tie the mount to that
|
|
176
|
+
* plugin's fiber: unloading the plugin then removes the mount even if the
|
|
177
|
+
* returned disposer is never called. Without an owner the mount lives until
|
|
178
|
+
* the disposer runs or this plugin unloads, so keep it in your own effect.
|
|
179
|
+
*
|
|
180
|
+
* @param matcher - tool name, `*` glob, RegExp, array of those, or a predicate over the call.
|
|
181
|
+
* @param mountOptions - title, fields, layout, labels, buttons, countdown, reject, modify, whenUnavailable.
|
|
182
|
+
* @param owner - the calling plugin's Cordis context, for lifetime binding.
|
|
183
|
+
* @returns a disposer removing this exact mount.
|
|
184
|
+
*/
|
|
185
|
+
function protect(matcher, mountOptions = {}, owner) {
|
|
186
|
+
const normalized = normalizeMount({ matcher, ...mountOptions }, 'hitl.protect()')
|
|
187
|
+
for (const message of normalized.warnings) warn(message)
|
|
188
|
+
if (!normalized.ok) throw new TypeError(`dsh-hitl: unusable tool matcher ${String(matcher)}`)
|
|
189
|
+
mounts.push(normalized.mount)
|
|
190
|
+
const release = () => {
|
|
191
|
+
const index = mounts.indexOf(normalized.mount)
|
|
192
|
+
if (index !== -1) mounts.splice(index, 1)
|
|
193
|
+
}
|
|
194
|
+
// A mount must not outlive the plugin that asked for it, and returning a
|
|
195
|
+
// disposer from `apply` is not a lifetime: only an effect on the caller's
|
|
196
|
+
// own context is disposed when that plugin unloads.
|
|
197
|
+
if (owner !== undefined && owner !== null && typeof owner.effect === 'function') {
|
|
198
|
+
try {
|
|
199
|
+
owner.effect(() => release, `dsh-hitl: mount ${normalized.mount.describe}`)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
warn(`the mount for ${normalized.mount.describe} could not be tied to its owner: ${String(error)}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return release
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Remove every mount whose matcher reads like the given one; returns how many. */
|
|
208
|
+
function unprotect(matcher) {
|
|
209
|
+
const target = normalizeMount({ matcher }, 'hitl.unprotect()')
|
|
210
|
+
if (!target.ok) return 0
|
|
211
|
+
const before = mounts.length
|
|
212
|
+
for (let index = mounts.length - 1; index >= 0; index -= 1) {
|
|
213
|
+
if (mounts[index].describe === target.mount.describe) mounts.splice(index, 1)
|
|
214
|
+
}
|
|
215
|
+
return before - mounts.length
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const service = {
|
|
219
|
+
protect,
|
|
220
|
+
unprotect,
|
|
221
|
+
/** Every mount currently registered, newest last. */
|
|
222
|
+
list: () => mounts.map(mount => ({
|
|
223
|
+
matcher: mount.describe,
|
|
224
|
+
layout: mount.options.layout,
|
|
225
|
+
title: mount.options.title,
|
|
226
|
+
countdown: mount.options.countdown,
|
|
227
|
+
rejectFeedback: mount.options.reject.feedback,
|
|
228
|
+
modify: mount.options.modify.mode,
|
|
229
|
+
whenUnavailable: mount.options.whenUnavailable,
|
|
230
|
+
})),
|
|
231
|
+
/** Every decision still waiting for a human, oldest first. */
|
|
232
|
+
pending: () => registry.list().map(request => ({
|
|
233
|
+
id: request.id,
|
|
234
|
+
sessionId: request.sessionId,
|
|
235
|
+
toolName: request.toolName,
|
|
236
|
+
createdAt: request.createdAt,
|
|
237
|
+
})),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rowMounts = normalizeProtectList(options.protect, 'config.protect')
|
|
241
|
+
for (const message of rowMounts.warnings) warn(message)
|
|
242
|
+
mounts.push(...rowMounts.mounts)
|
|
243
|
+
|
|
244
|
+
// ── the gate ───────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
function unavailable(exec, reason) {
|
|
247
|
+
return {
|
|
248
|
+
kind: 'deny',
|
|
249
|
+
reason: capReason(`HITL: ${reason}, so tool ${JSON.stringify(exec.name)} did not run (fail-closed).`),
|
|
250
|
+
info: FAILURES.unavailable,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Wait for one human decision, or for the host to withdraw the request. */
|
|
255
|
+
function waitForDecision(id, request, mount, exec) {
|
|
256
|
+
return new Promise(resolve => {
|
|
257
|
+
const countdown = mount.options.countdown === null ? null : {
|
|
258
|
+
remainingMs: mount.options.countdown.seconds * 1000,
|
|
259
|
+
action: mount.options.countdown.action,
|
|
260
|
+
freezeOnInteract: mount.options.countdown.freezeOnInteract,
|
|
261
|
+
}
|
|
262
|
+
resolvers.set(id, resolve)
|
|
263
|
+
const opened = registry.open({ id, request, countdown })
|
|
264
|
+
if (!opened.ok) {
|
|
265
|
+
resolvers.delete(id)
|
|
266
|
+
resolve({ decision: { kind: 'cancel' }, source: OUTCOMES.host })
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const signal = exec.signal
|
|
270
|
+
if (signal !== undefined && typeof signal.addEventListener === 'function') {
|
|
271
|
+
if (signal.aborted) {
|
|
272
|
+
registry.abort(id)
|
|
273
|
+
} else {
|
|
274
|
+
const onAbort = () => { registry.abort(id) }
|
|
275
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
276
|
+
cleanups.set(id, () => { signal.removeEventListener('abort', onAbort) })
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
broadcast(frameRequest(liveRequest(request)))
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Map one settled decision onto the pre-execution decision vocabulary.
|
|
285
|
+
* @param exec - the pending call, for its name and call id.
|
|
286
|
+
* @param request - the wire request the human decided on.
|
|
287
|
+
* @param mount - the mount that gated the call.
|
|
288
|
+
* @param settlement - `{ decision, source }` from the registry.
|
|
289
|
+
* @returns the decision the tool registry applies.
|
|
290
|
+
*/
|
|
291
|
+
function toPreToolDecision(exec, request, mount, settlement) {
|
|
292
|
+
if (settlement.decision.kind === 'approve') return { kind: 'allow' }
|
|
293
|
+
if (settlement.decision.kind === 'cancel') return { kind: 'cancel' }
|
|
294
|
+
const revising = isRevision(settlement.decision)
|
|
295
|
+
if (revising && mount.options.modify.mode === 'allow-and-inform' && settlement.source === OUTCOMES.user) {
|
|
296
|
+
if (exec.callId !== undefined) revisions.set(String(exec.callId), { request, decision: settlement.decision })
|
|
297
|
+
return { kind: 'allow' }
|
|
298
|
+
}
|
|
299
|
+
const info = settlement.source === OUTCOMES.timeout
|
|
300
|
+
? FAILURES.timeout
|
|
301
|
+
: (revising ? FAILURES.revised : FAILURES.rejected)
|
|
302
|
+
return {
|
|
303
|
+
kind: 'deny',
|
|
304
|
+
reason: capReason(describeDecision(request, settlement.decision, settlement.source)),
|
|
305
|
+
info,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function gate(exec, next) {
|
|
310
|
+
let mount
|
|
311
|
+
try {
|
|
312
|
+
mount = matchMount(mounts, exec)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
warn(`a mount matcher failed: ${String(error)}`)
|
|
315
|
+
return next()
|
|
316
|
+
}
|
|
317
|
+
if (mount === undefined || !mountApplies(mount, exec)) return next()
|
|
318
|
+
const sessionId = exec.agent === undefined || exec.agent === null ? undefined : exec.agent.id
|
|
319
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
320
|
+
warn(`tool ${JSON.stringify(exec.name)} is mounted on HITL but its call carries no agent to route a decision through`)
|
|
321
|
+
return unavailable(exec, 'the call has no agent to route a decision through')
|
|
322
|
+
}
|
|
323
|
+
if (clients.size === 0 && mount.options.whenUnavailable === 'reject') {
|
|
324
|
+
warn(`tool ${JSON.stringify(exec.name)} needs a human decision but no browser is connected`)
|
|
325
|
+
return unavailable(exec, 'no browser is connected to decide')
|
|
326
|
+
}
|
|
327
|
+
const id = `hitl:${String(exec.callId ?? randomUUID())}`
|
|
328
|
+
try {
|
|
329
|
+
const request = buildRequest({ execution: exec, mount, sessionId, id })
|
|
330
|
+
const settlement = await waitForDecision(id, request, mount, exec)
|
|
331
|
+
return toPreToolDecision(exec, request, mount, settlement)
|
|
332
|
+
} catch (error) {
|
|
333
|
+
warn(`tool ${JSON.stringify(exec.name)} could not be put to a human: ${String(error)}`)
|
|
334
|
+
registry.abort(id)
|
|
335
|
+
return unavailable(exec, 'the HITL panel failed to open')
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
ctx.on('tools/pre-execute', (exec, next) => gate(exec, next), { prepend: true })
|
|
340
|
+
|
|
341
|
+
// ── approved-with-edits context ────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Hand the user's revision to the model next to the result it approved, in
|
|
345
|
+
* the `allow-and-inform` modify mode. The message is built to the shape
|
|
346
|
+
* `createUserMessage` produces, because a plain bundle cannot import that
|
|
347
|
+
* factory; `source.kind` is the standard `user` one.
|
|
348
|
+
*/
|
|
349
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
350
|
+
const decision = await next()
|
|
351
|
+
if (revisions.size === 0 || exec.callId === undefined) return decision
|
|
352
|
+
const key = String(exec.callId)
|
|
353
|
+
const revision = revisions.get(key)
|
|
354
|
+
if (revision === undefined) return decision
|
|
355
|
+
revisions.delete(key)
|
|
356
|
+
try {
|
|
357
|
+
const message = {
|
|
358
|
+
id: randomUUID(),
|
|
359
|
+
role: 'user',
|
|
360
|
+
source: { kind: 'user' },
|
|
361
|
+
content: [{ type: 'text', text: capReason(revisionContext(revision.request, revision.decision)) }],
|
|
362
|
+
}
|
|
363
|
+
return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), message] }
|
|
364
|
+
} catch (error) {
|
|
365
|
+
warn(`the revision context for ${key} was dropped: ${String(error)}`)
|
|
366
|
+
return decision
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// ── transport ──────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
function authorized(req, queryToken) {
|
|
373
|
+
const header = req.headers.authorization
|
|
374
|
+
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : undefined
|
|
375
|
+
const presented = typeof queryToken === 'string' && queryToken !== '' ? queryToken : bearer
|
|
376
|
+
if (typeof presented !== 'string' || presented === '') return false
|
|
377
|
+
const candidate = Buffer.from(presented)
|
|
378
|
+
let accepted = false
|
|
379
|
+
for (const known of tokens) {
|
|
380
|
+
const expected = Buffer.from(known)
|
|
381
|
+
// Every candidate is compared, so timing does not reveal which token matched.
|
|
382
|
+
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) accepted = true
|
|
383
|
+
}
|
|
384
|
+
return accepted
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function loopback(req) {
|
|
388
|
+
const address = req.socket?.remoteAddress
|
|
389
|
+
return typeof address === 'string' && LOOPBACK.has(address)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function sameOrigin(req) {
|
|
393
|
+
const origin = req.headers.origin
|
|
394
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
395
|
+
const host = req.headers.host
|
|
396
|
+
if (typeof host !== 'string' || host === '') return false
|
|
397
|
+
try {
|
|
398
|
+
return new URL(origin).host === host
|
|
399
|
+
} catch {
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sendJson(res, status, body) {
|
|
405
|
+
const text = JSON.stringify(body)
|
|
406
|
+
res.writeHead(status, {
|
|
407
|
+
'content-type': 'application/json; charset=utf-8',
|
|
408
|
+
'content-length': Buffer.byteLength(text),
|
|
409
|
+
})
|
|
410
|
+
res.end(text)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function refuse(res, status, code, message) {
|
|
414
|
+
sendJson(res, status, errorBody(code, message))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function openEvents(req, res, url) {
|
|
418
|
+
if (!loopback(req) || !authorized(req, url.searchParams.get('token'))) {
|
|
419
|
+
refuse(res, 401, ERROR_CODES.unauthorized, 'a valid decision token is required')
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
res.writeHead(200, {
|
|
423
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
424
|
+
'cache-control': 'no-cache, no-transform',
|
|
425
|
+
connection: 'keep-alive',
|
|
426
|
+
'x-accel-buffering': 'no',
|
|
427
|
+
})
|
|
428
|
+
const client = { res, heartbeat: undefined }
|
|
429
|
+
// A real frame, not an SSE comment: only a frame proves to the browser half
|
|
430
|
+
// that this stream is still the live one, so its watchdog can tell a silent
|
|
431
|
+
// orphaned stream from a healthy one.
|
|
432
|
+
client.heartbeat = setInterval(() => {
|
|
433
|
+
try {
|
|
434
|
+
res.write(sseChunk(framePing()))
|
|
435
|
+
} catch {
|
|
436
|
+
dropClient(client)
|
|
437
|
+
}
|
|
438
|
+
}, HEARTBEAT_MS)
|
|
439
|
+
if (typeof client.heartbeat?.unref === 'function') client.heartbeat.unref()
|
|
440
|
+
clients.add(client)
|
|
441
|
+
try {
|
|
442
|
+
res.write(sseChunk(frameSnapshot(registry.list().map(liveRequest))))
|
|
443
|
+
} catch (error) {
|
|
444
|
+
warn(`the pending snapshot could not be written: ${String(error)}`)
|
|
445
|
+
dropClient(client)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
req.on('close', () => { dropClient(client) })
|
|
449
|
+
req.on('error', () => { dropClient(client) })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function readBody(req) {
|
|
453
|
+
return new Promise((resolve, reject) => {
|
|
454
|
+
const chunks = []
|
|
455
|
+
let size = 0
|
|
456
|
+
req.on('data', chunk => {
|
|
457
|
+
size += chunk.length
|
|
458
|
+
if (size > LIMITS.maxBodyBytes) {
|
|
459
|
+
reject(new Error('payload too large'))
|
|
460
|
+
req.destroy()
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
chunks.push(chunk)
|
|
464
|
+
})
|
|
465
|
+
req.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')) })
|
|
466
|
+
req.on('error', reject)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function decide(req, res, url) {
|
|
471
|
+
if (!loopback(req) || !authorized(req, url.searchParams.get('token')) || !sameOrigin(req)) {
|
|
472
|
+
refuse(res, 401, ERROR_CODES.unauthorized, 'a valid decision token is required')
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
const contentType = req.headers['content-type']
|
|
476
|
+
if (typeof contentType !== 'string' || !contentType.includes('application/json')) {
|
|
477
|
+
refuse(res, 400, ERROR_CODES.badPayload, 'content-type must be application/json')
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
let raw
|
|
481
|
+
try {
|
|
482
|
+
raw = await readBody(req)
|
|
483
|
+
} catch (error) {
|
|
484
|
+
refuse(res, 413, ERROR_CODES.badPayload, error instanceof Error ? error.message : String(error))
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
let payload
|
|
488
|
+
try {
|
|
489
|
+
payload = JSON.parse(raw === '' ? 'null' : raw)
|
|
490
|
+
} catch {
|
|
491
|
+
refuse(res, 400, ERROR_CODES.badPayload, 'the body must be JSON')
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
const parsed = parseUplink(payload)
|
|
495
|
+
if (!parsed.ok) {
|
|
496
|
+
refuse(res, 400, parsed.code, parsed.message)
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const uplink = parsed.value
|
|
500
|
+
if (uplink.type === 'hold') {
|
|
501
|
+
const held = registry.hold(uplink.id, uplink.held)
|
|
502
|
+
if (!held.ok) {
|
|
503
|
+
refuse(res, held.code === ERROR_CODES.unknownRequest ? 404 : 409, held.code, 'that decision is no longer open')
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
broadcast(frameHoldAck(uplink.id, held.held, held.expiresAt, registry.remaining(uplink.id)))
|
|
507
|
+
sendJson(res, 200, {
|
|
508
|
+
...okBody(true),
|
|
509
|
+
held: held.held,
|
|
510
|
+
...(held.expiresAt === undefined ? {} : { expiresAt: held.expiresAt }),
|
|
511
|
+
})
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
const settled = registry.settle(uplink.id, uplink.decision)
|
|
515
|
+
if (!settled.accepted) {
|
|
516
|
+
refuse(res, settled.code === ERROR_CODES.unknownRequest ? 404 : 409, settled.code, 'that decision is no longer open')
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
sendJson(res, 200, okBody(true))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function handler(req, res) {
|
|
523
|
+
let url
|
|
524
|
+
try {
|
|
525
|
+
url = new URL(req.url ?? '/', 'http://localhost')
|
|
526
|
+
} catch {
|
|
527
|
+
refuse(res, 400, ERROR_CODES.badPayload, 'unreadable request target')
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
if (url.pathname === `${endpoint}/events`) {
|
|
531
|
+
if (req.method !== 'GET') {
|
|
532
|
+
refuse(res, 405, ERROR_CODES.badPayload, 'the event stream is a GET route')
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
openEvents(req, res, url)
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
if (url.pathname === `${endpoint}/status`) {
|
|
539
|
+
// Read-only troubleshooting surface: no token, no payload, loopback only.
|
|
540
|
+
// It exists so a developer can answer "is a browser attached, what is
|
|
541
|
+
// gated right now, and how many tokens did this process issue" without
|
|
542
|
+
// reading logs or guessing.
|
|
543
|
+
if (req.method !== 'GET' || !loopback(req)) {
|
|
544
|
+
refuse(res, 401, ERROR_CODES.unauthorized, 'the status route is a loopback GET')
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
sendJson(res, 200, {
|
|
548
|
+
ok: true,
|
|
549
|
+
clients: clients.size,
|
|
550
|
+
tokens: tokens.size,
|
|
551
|
+
protecting: service.list(),
|
|
552
|
+
pending: service.pending().map(request => ({
|
|
553
|
+
...request,
|
|
554
|
+
remainingMs: registry.remaining(request.id),
|
|
555
|
+
held: registry.held(request.id),
|
|
556
|
+
})),
|
|
557
|
+
})
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
if (url.pathname === `${endpoint}/decide`) {
|
|
561
|
+
if (req.method !== 'POST') {
|
|
562
|
+
refuse(res, 405, ERROR_CODES.badPayload, 'decisions arrive on POST')
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
void decide(req, res, url).catch((error) => {
|
|
566
|
+
warn(`the decision route failed: ${String(error)}`)
|
|
567
|
+
try {
|
|
568
|
+
refuse(res, 500, ERROR_CODES.hostGone, 'the host could not accept that decision')
|
|
569
|
+
} catch {
|
|
570
|
+
/* the response is already gone */
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
refuse(res, 404, ERROR_CODES.badPayload, 'unknown HITL route')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
ctx.inject(['webServer'], webCtx => {
|
|
579
|
+
webCtx.effect(
|
|
580
|
+
() => webCtx.webServer.register({ kind: 'prefix', path: endpoint, handler }),
|
|
581
|
+
'dsh-hitl: decision transport',
|
|
582
|
+
)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
ctx.on('webserver/index-inject', table => {
|
|
586
|
+
table.push({ kind: 'global', name: GLOBAL_KEY, value: { token, endpoint } })
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
// ── lifetime ───────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
ctx.effect(() => {
|
|
592
|
+
const disposers = [ctx.provide('hitl', service)]
|
|
593
|
+
return () => {
|
|
594
|
+
for (const dispose of disposers.reverse()) {
|
|
595
|
+
try {
|
|
596
|
+
dispose()
|
|
597
|
+
} catch (error) {
|
|
598
|
+
warn(`service disposal failed: ${String(error)}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}, 'dsh-hitl: hitl service')
|
|
603
|
+
|
|
604
|
+
ctx.effect(() => () => {
|
|
605
|
+
registry.drain(OUTCOMES.host)
|
|
606
|
+
// End, do not merely forget: a reload leaves each open socket owned by a
|
|
607
|
+
// dead closure, and a browser cannot see that. Closing it makes the browser
|
|
608
|
+
// reconnect to the next instance instead of waiting on a silent stream.
|
|
609
|
+
for (const client of [...clients]) {
|
|
610
|
+
dropClient(client)
|
|
611
|
+
try {
|
|
612
|
+
client.res.end()
|
|
613
|
+
} catch {
|
|
614
|
+
/* the socket is already gone */
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const cleanup of cleanups.values()) {
|
|
618
|
+
try {
|
|
619
|
+
cleanup()
|
|
620
|
+
} catch {
|
|
621
|
+
/* listeners are already being removed with the fiber */
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
mounts.length = 0
|
|
625
|
+
revisions.clear()
|
|
626
|
+
resolvers.clear()
|
|
627
|
+
cleanups.clear()
|
|
628
|
+
}, 'dsh-hitl: shutdown')
|
|
629
|
+
}
|