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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { ERROR_CODES, OUTCOMES } from '../lib/protocol.js'
|
|
5
|
+
import { createPendingRegistry, timeoutDecision } from '../lib/pending.js'
|
|
6
|
+
|
|
7
|
+
/** Deterministic clock/timers so seconds pass in one call. */
|
|
8
|
+
function fakeClock() {
|
|
9
|
+
let current = 0
|
|
10
|
+
let nextId = 1
|
|
11
|
+
const timers = new Map()
|
|
12
|
+
return {
|
|
13
|
+
now: () => current,
|
|
14
|
+
setTimer: (callback, delay) => {
|
|
15
|
+
const id = nextId
|
|
16
|
+
nextId += 1
|
|
17
|
+
timers.set(id, { at: current + Math.max(0, delay), callback })
|
|
18
|
+
return id
|
|
19
|
+
},
|
|
20
|
+
clearTimer: id => { timers.delete(id) },
|
|
21
|
+
pendingTimers: () => timers.size,
|
|
22
|
+
advance(ms) {
|
|
23
|
+
const target = current + ms
|
|
24
|
+
for (let guard = 0; guard < 1000; guard += 1) {
|
|
25
|
+
let next
|
|
26
|
+
for (const [id, timer] of timers) {
|
|
27
|
+
if (timer.at <= target && (next === undefined || timer.at < next.timer.at)) next = { id, timer }
|
|
28
|
+
}
|
|
29
|
+
if (next === undefined) break
|
|
30
|
+
timers.delete(next.id)
|
|
31
|
+
current = next.timer.at
|
|
32
|
+
next.timer.callback()
|
|
33
|
+
}
|
|
34
|
+
current = target
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One registry wired to a fake clock and the settlements it produced. */
|
|
40
|
+
function registryOf(options = {}) {
|
|
41
|
+
const clock = fakeClock()
|
|
42
|
+
const settlements = []
|
|
43
|
+
const registry = createPendingRegistry({
|
|
44
|
+
now: clock.now,
|
|
45
|
+
setTimer: clock.setTimer,
|
|
46
|
+
clearTimer: clock.clearTimer,
|
|
47
|
+
onSettle: (entry, settlement) => { settlements.push({ id: entry.id, request: entry.request, ...settlement }) },
|
|
48
|
+
...options,
|
|
49
|
+
})
|
|
50
|
+
return { clock, settlements, registry }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const REQUEST = { id: 'r1', sessionId: 's1', toolName: 'bash', countdown: null }
|
|
54
|
+
|
|
55
|
+
describe('pending: settlement', () => {
|
|
56
|
+
it('accepts the first decision and reports later ones', () => {
|
|
57
|
+
const { registry, settlements } = registryOf()
|
|
58
|
+
assert.deepEqual(registry.open({ id: 'r1', request: REQUEST, countdown: null, onTimeout: undefined }), { ok: true, expiresAt: undefined })
|
|
59
|
+
assert.equal(registry.has('r1'), true)
|
|
60
|
+
assert.deepEqual(registry.list(), [REQUEST])
|
|
61
|
+
assert.equal(registry.size(), 1)
|
|
62
|
+
assert.deepEqual(registry.settle('r1', { kind: 'approve' }), { accepted: true })
|
|
63
|
+
assert.equal(registry.has('r1'), false)
|
|
64
|
+
assert.equal(registry.size(), 0)
|
|
65
|
+
assert.deepEqual(settlements, [{ id: 'r1', request: REQUEST, decision: { kind: 'approve' }, source: OUTCOMES.user }])
|
|
66
|
+
assert.deepEqual(registry.settle('r1', { kind: 'reject' }), { accepted: false, code: ERROR_CODES.alreadySettled })
|
|
67
|
+
assert.deepEqual(registry.settle('nope', { kind: 'reject' }), { accepted: false, code: ERROR_CODES.unknownRequest })
|
|
68
|
+
assert.deepEqual(registry.settled(), ['r1'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('refuses a duplicate identity', () => {
|
|
72
|
+
const { registry } = registryOf()
|
|
73
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: null })
|
|
74
|
+
assert.deepEqual(registry.open({ id: 'r1', request: REQUEST, countdown: null }), { ok: false, code: ERROR_CODES.alreadySettled })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('exposes the open request until it settles', () => {
|
|
78
|
+
const { registry } = registryOf()
|
|
79
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: null })
|
|
80
|
+
assert.equal(registry.get('r1'), REQUEST)
|
|
81
|
+
registry.settle('r1', { kind: 'approve' })
|
|
82
|
+
assert.equal(registry.get('r1'), undefined)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('pending: countdown', () => {
|
|
87
|
+
it('maps the three actions', () => {
|
|
88
|
+
assert.deepEqual(timeoutDecision('approve'), { kind: 'approve' })
|
|
89
|
+
assert.deepEqual(timeoutDecision('reject'), { kind: 'reject' })
|
|
90
|
+
assert.deepEqual(timeoutDecision('notify'), { kind: 'reject' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('settles at the deadline with the configured action', () => {
|
|
94
|
+
const { registry, clock, settlements } = registryOf()
|
|
95
|
+
const opened = registry.open({
|
|
96
|
+
id: 'r1', request: REQUEST, countdown: { remainingMs: 30000, action: 'notify', freezeOnInteract: true },
|
|
97
|
+
})
|
|
98
|
+
assert.deepEqual(opened, { ok: true, expiresAt: 30000 })
|
|
99
|
+
clock.advance(29999)
|
|
100
|
+
assert.equal(registry.size(), 1)
|
|
101
|
+
clock.advance(1)
|
|
102
|
+
assert.deepEqual(settlements, [{ id: 'r1', request: REQUEST, decision: { kind: 'reject' }, source: OUTCOMES.timeout }])
|
|
103
|
+
assert.equal(clock.pendingTimers(), 0)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('approves on timeout when configured to', () => {
|
|
107
|
+
const { registry, clock, settlements } = registryOf()
|
|
108
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 1000, action: 'approve', freezeOnInteract: true } })
|
|
109
|
+
clock.advance(1000)
|
|
110
|
+
assert.equal(settlements[0].decision.kind, 'approve')
|
|
111
|
+
assert.equal(settlements[0].source, OUTCOMES.timeout)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reports the remaining time and stops counting once settled', () => {
|
|
115
|
+
const { registry, clock } = registryOf()
|
|
116
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 10000, action: 'reject', freezeOnInteract: true } })
|
|
117
|
+
clock.advance(4000)
|
|
118
|
+
assert.equal(registry.remaining('r1'), 6000)
|
|
119
|
+
registry.settle('r1', { kind: 'approve' })
|
|
120
|
+
assert.equal(registry.remaining('r1'), null)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('has no countdown when the mount configured none', () => {
|
|
124
|
+
const { registry, clock, settlements } = registryOf()
|
|
125
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: null })
|
|
126
|
+
clock.advance(10 ** 7)
|
|
127
|
+
assert.equal(settlements.length, 0)
|
|
128
|
+
assert.equal(registry.remaining('r1'), null)
|
|
129
|
+
assert.equal(clock.pendingTimers(), 0)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('pending: interaction hold', () => {
|
|
134
|
+
it('freezes the countdown and resumes with the frozen remainder', () => {
|
|
135
|
+
const { registry, clock, settlements } = registryOf()
|
|
136
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 10000, action: 'reject', freezeOnInteract: true } })
|
|
137
|
+
clock.advance(4000)
|
|
138
|
+
assert.deepEqual(registry.hold('r1', true), { ok: true, held: true, expiresAt: 4000 + 300000 })
|
|
139
|
+
clock.advance(60000)
|
|
140
|
+
assert.equal(settlements.length, 0)
|
|
141
|
+
assert.equal(registry.remaining('r1'), 6000)
|
|
142
|
+
assert.deepEqual(registry.hold('r1', false), { ok: true, held: false })
|
|
143
|
+
clock.advance(5999)
|
|
144
|
+
assert.equal(settlements.length, 0)
|
|
145
|
+
clock.advance(1)
|
|
146
|
+
assert.equal(settlements.length, 1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('is idempotent while held', () => {
|
|
150
|
+
const { registry } = registryOf()
|
|
151
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 10000, action: 'reject', freezeOnInteract: true } })
|
|
152
|
+
const first = registry.hold('r1', true)
|
|
153
|
+
assert.deepEqual(registry.hold('r1', true), first)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('releases the hold on its own after the grace window', () => {
|
|
157
|
+
const { registry, clock, settlements } = registryOf({ holdGraceMs: 1000 })
|
|
158
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 5000, action: 'reject', freezeOnInteract: true } })
|
|
159
|
+
clock.advance(1000)
|
|
160
|
+
assert.deepEqual(registry.hold('r1', true), { ok: true, held: true, expiresAt: 2000 })
|
|
161
|
+
clock.advance(1000)
|
|
162
|
+
clock.advance(5000)
|
|
163
|
+
assert.equal(settlements.length, 1)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('ignores a hold the mount did not opt into, or a request without a countdown', () => {
|
|
167
|
+
const { registry, settlements, clock } = registryOf()
|
|
168
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 1000, action: 'reject', freezeOnInteract: false } })
|
|
169
|
+
assert.deepEqual(registry.hold('r1', true), { ok: true, held: false })
|
|
170
|
+
registry.open({ id: 'r2', request: REQUEST, countdown: null })
|
|
171
|
+
assert.deepEqual(registry.hold('r2', true), { ok: true, held: false })
|
|
172
|
+
clock.advance(1000)
|
|
173
|
+
assert.equal(settlements.length, 1)
|
|
174
|
+
assert.equal(settlements[0].id, 'r1')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('reports unknown and settled identities', () => {
|
|
178
|
+
const { registry } = registryOf()
|
|
179
|
+
assert.deepEqual(registry.hold('nope', true), { ok: false, code: ERROR_CODES.unknownRequest })
|
|
180
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: null })
|
|
181
|
+
registry.settle('r1', { kind: 'approve' })
|
|
182
|
+
assert.deepEqual(registry.hold('r1', true), { ok: false, code: ERROR_CODES.alreadySettled })
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('drops a hold timer when the request settles', () => {
|
|
186
|
+
const { registry, clock, settlements } = registryOf({ holdGraceMs: 60000 })
|
|
187
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 60000, action: 'reject', freezeOnInteract: true } })
|
|
188
|
+
registry.hold('r1', true)
|
|
189
|
+
assert.equal(clock.pendingTimers(), 1)
|
|
190
|
+
registry.settle('r1', { kind: 'approve' })
|
|
191
|
+
assert.equal(clock.pendingTimers(), 0)
|
|
192
|
+
clock.advance(120000)
|
|
193
|
+
assert.equal(settlements.length, 1)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('pending: withdrawal', () => {
|
|
198
|
+
it('cancels through abort and drain', () => {
|
|
199
|
+
const { registry, settlements, clock } = registryOf()
|
|
200
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 1000, action: 'reject', freezeOnInteract: true } })
|
|
201
|
+
registry.open({ id: 'r2', request: REQUEST, countdown: null })
|
|
202
|
+
assert.deepEqual(registry.abort('r2'), { accepted: true })
|
|
203
|
+
assert.deepEqual(registry.drain(), ['r1'])
|
|
204
|
+
assert.equal(registry.size(), 0)
|
|
205
|
+
assert.deepEqual(settlements.map(entry => [entry.id, entry.decision, entry.source]), [
|
|
206
|
+
['r2', { kind: 'cancel' }, OUTCOMES.abort],
|
|
207
|
+
['r1', { kind: 'cancel' }, OUTCOMES.host],
|
|
208
|
+
])
|
|
209
|
+
clock.advance(10000)
|
|
210
|
+
assert.equal(settlements.length, 2)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('drains nothing when no request is open', () => {
|
|
214
|
+
const { registry } = registryOf()
|
|
215
|
+
assert.deepEqual(registry.drain(), [])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('pending: projected countdown state', () => {
|
|
220
|
+
it('reports the frozen remainder and the hold while a client holds it', () => {
|
|
221
|
+
const { registry, clock } = registryOf()
|
|
222
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: { remainingMs: 30000, action: 'reject', freezeOnInteract: true } })
|
|
223
|
+
clock.advance(10000)
|
|
224
|
+
assert.equal(registry.remaining('r1'), 20000)
|
|
225
|
+
assert.equal(registry.held('r1'), false)
|
|
226
|
+
registry.hold('r1', true)
|
|
227
|
+
assert.equal(registry.held('r1'), true)
|
|
228
|
+
assert.equal(registry.remaining('r1'), 20000, 'a held countdown reads its frozen remainder')
|
|
229
|
+
registry.hold('r1', false)
|
|
230
|
+
assert.equal(registry.held('r1'), false)
|
|
231
|
+
assert.equal(registry.remaining('r1'), 20000)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('reports no countdown and no hold for a request without one', () => {
|
|
235
|
+
const { registry } = registryOf()
|
|
236
|
+
registry.open({ id: 'r1', request: REQUEST, countdown: null })
|
|
237
|
+
assert.equal(registry.remaining('r1'), null)
|
|
238
|
+
assert.equal(registry.held('r1'), false)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ERROR_CODES, LIMITS, PROTOCOL_VERSION, errorBody, frameHoldAck, frameRequest, frameSettled,
|
|
6
|
+
frameSnapshot, isDecision, isRecord, okBody, parseUplink, sseChunk, truncate,
|
|
7
|
+
} from '../lib/protocol.js'
|
|
8
|
+
|
|
9
|
+
describe('protocol: guards', () => {
|
|
10
|
+
it('accepts plain records only', () => {
|
|
11
|
+
assert.equal(isRecord({}), true)
|
|
12
|
+
assert.equal(isRecord([]), false)
|
|
13
|
+
assert.equal(isRecord(null), false)
|
|
14
|
+
assert.equal(isRecord('x'), false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('accepts the three decision kinds', () => {
|
|
18
|
+
assert.equal(isDecision({ kind: 'approve' }), true)
|
|
19
|
+
assert.equal(isDecision({ kind: 'reject' }), true)
|
|
20
|
+
assert.equal(isDecision({ kind: 'reject', feedback: 'no' }), true)
|
|
21
|
+
assert.equal(isDecision({ kind: 'modify', fields: [{ param: 'a', text: 'b' }] }), true)
|
|
22
|
+
assert.equal(isDecision({ kind: 'modify', fields: [] }), true)
|
|
23
|
+
assert.equal(isDecision({ kind: 'cancel' }), false)
|
|
24
|
+
assert.equal(isDecision({ kind: 'modify', fields: [{ param: 'a' }] }), false)
|
|
25
|
+
assert.equal(isDecision({ kind: 'reject', feedback: 7 }), false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('protocol: parseUplink', () => {
|
|
30
|
+
it('rejects non-objects and missing ids', () => {
|
|
31
|
+
assert.equal(parseUplink(null).code, ERROR_CODES.badPayload)
|
|
32
|
+
assert.equal(parseUplink({ type: 'decide', decision: { kind: 'approve' } }).code, ERROR_CODES.badPayload)
|
|
33
|
+
assert.equal(parseUplink({ id: '', type: 'decide', decision: { kind: 'approve' } }).code, ERROR_CODES.badPayload)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('rejects unknown types and decisions', () => {
|
|
37
|
+
assert.equal(parseUplink({ id: 'a', type: 'nope' }).code, ERROR_CODES.badPayload)
|
|
38
|
+
assert.equal(parseUplink({ id: 'a', type: 'decide', decision: { kind: 'maybe' } }).code, ERROR_CODES.badPayload)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('normalizes an approve decision', () => {
|
|
42
|
+
const parsed = parseUplink({ id: 'a', type: 'decide', decision: { kind: 'approve' } })
|
|
43
|
+
assert.deepEqual(parsed.value, { type: 'decide', id: 'a', decision: { kind: 'approve' } })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('drops empty reject feedback', () => {
|
|
47
|
+
assert.deepEqual(parseUplink({ id: 'a', type: 'decide', decision: { kind: 'reject', feedback: ' ' } }).value.decision, { kind: 'reject' })
|
|
48
|
+
assert.deepEqual(parseUplink({ id: 'a', type: 'decide', decision: { kind: 'reject', feedback: ' 慢 ' } }).value.decision, { kind: 'reject', feedback: '慢' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('requires at least one changed field for a modify decision', () => {
|
|
52
|
+
assert.equal(parseUplink({ id: 'a', type: 'decide', decision: { kind: 'modify', fields: [] } }).code, ERROR_CODES.badPayload)
|
|
53
|
+
const parsed = parseUplink({
|
|
54
|
+
id: 'a', type: 'decide', decision: { kind: 'modify', fields: [{ param: 'cmd', text: 'ls' }, { param: '', text: 'x' }] },
|
|
55
|
+
})
|
|
56
|
+
assert.deepEqual(parsed.value.decision.fields, [{ param: 'cmd', text: 'ls' }])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('normalizes a hold frame', () => {
|
|
60
|
+
assert.deepEqual(parseUplink({ id: 'a', type: 'hold', held: true }).value, { type: 'hold', id: 'a', held: true })
|
|
61
|
+
assert.equal(parseUplink({ id: 'a', type: 'hold', held: 'yes' }).code, ERROR_CODES.badPayload)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('protocol: frames', () => {
|
|
66
|
+
it('stamps the version and keeps optional fields out when absent', () => {
|
|
67
|
+
assert.deepEqual(frameSnapshot([]), { type: 'snapshot', version: PROTOCOL_VERSION, requests: [] })
|
|
68
|
+
assert.deepEqual(frameRequest({ id: 'a' }), { type: 'request', version: PROTOCOL_VERSION, request: { id: 'a' } })
|
|
69
|
+
assert.deepEqual(frameSettled('a', 'user'), { type: 'settled', version: PROTOCOL_VERSION, id: 'a', outcome: 'user' })
|
|
70
|
+
assert.deepEqual(frameSettled('a', 'user', 'client-1'), { type: 'settled', version: PROTOCOL_VERSION, id: 'a', outcome: 'user', decidedBy: 'client-1' })
|
|
71
|
+
assert.deepEqual(frameHoldAck('a', true, 12), { type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: true, expiresAt: 12 })
|
|
72
|
+
assert.deepEqual(frameHoldAck('a', false), { type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: false })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('serializes one SSE data chunk', () => {
|
|
76
|
+
assert.equal(sseChunk({ type: 'ping' }), 'data: {"type":"ping"}\n\n')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('wraps route bodies', () => {
|
|
80
|
+
assert.deepEqual(errorBody('bad-payload', 'nope'), { ok: false, code: 'bad-payload', message: 'nope' })
|
|
81
|
+
assert.deepEqual(okBody(true), { ok: true, accepted: true })
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('protocol: truncate', () => {
|
|
86
|
+
it('passes short text through', () => {
|
|
87
|
+
assert.deepEqual(truncate('abc', 5), { text: 'abc', truncated: false })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('marks long text', () => {
|
|
91
|
+
const cut = truncate('abcdefghij', 4)
|
|
92
|
+
assert.equal(cut.truncated, true)
|
|
93
|
+
assert.equal(cut.text.startsWith('abcd'), true)
|
|
94
|
+
assert.equal(cut.text.includes('已截断,共 10 字'), true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('exposes sane default limits', () => {
|
|
98
|
+
assert.equal(LIMITS.maxFieldChars > 0, true)
|
|
99
|
+
assert.equal(LIMITS.maxBodyBytes > LIMITS.maxFieldChars, true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('protocol: hold acknowledgement', () => {
|
|
104
|
+
it('carries the host remainder when it has one', () => {
|
|
105
|
+
assert.deepEqual(frameHoldAck('a', true, 1200, 45000), {
|
|
106
|
+
type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: true, expiresAt: 1200, remainingMs: 45000,
|
|
107
|
+
})
|
|
108
|
+
assert.deepEqual(frameHoldAck('a', false, undefined, 30000), {
|
|
109
|
+
type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: false, remainingMs: 30000,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('omits a remainder it does not have', () => {
|
|
114
|
+
assert.deepEqual(frameHoldAck('a', false), { type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: false })
|
|
115
|
+
assert.deepEqual(frameHoldAck('a', false, undefined, null), { type: 'holdAck', version: PROTOCOL_VERSION, id: 'a', held: false })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_OPTIONS, createMatcher, defaultEditable, globToRegExp, matchMount, mountApplies,
|
|
6
|
+
normalizeMount, normalizeProtectList,
|
|
7
|
+
} from '../lib/resolve.js'
|
|
8
|
+
|
|
9
|
+
describe('resolve: globToRegExp', () => {
|
|
10
|
+
it('anchors the pattern and expands the star', () => {
|
|
11
|
+
const pattern = globToRegExp('mcp__*')
|
|
12
|
+
assert.equal(pattern.test('mcp__foo'), true)
|
|
13
|
+
assert.equal(pattern.test('xmcp__foo'), false)
|
|
14
|
+
assert.equal(pattern.test('mcp__'), true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps every other character literal', () => {
|
|
18
|
+
const pattern = globToRegExp('a.b*')
|
|
19
|
+
assert.equal(pattern.test('a.bX'), true)
|
|
20
|
+
assert.equal(pattern.test('axbX'), false)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('resolve: createMatcher', () => {
|
|
25
|
+
it('matches an exact name', () => {
|
|
26
|
+
assert.equal(createMatcher('bash').test({ name: 'bash' }), true)
|
|
27
|
+
assert.equal(createMatcher('bash').test({ name: 'bash2' }), false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('treats a star as a wildcard', () => {
|
|
31
|
+
assert.equal(createMatcher('mcp__*').test({ name: 'mcp__files' }), true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('supports a RegExp, a list, and a predicate', () => {
|
|
35
|
+
assert.equal(createMatcher(/^read/).test({ name: 'read_image' }), true)
|
|
36
|
+
assert.equal(createMatcher(['a', 'b*']).test({ name: 'banana' }), true)
|
|
37
|
+
assert.equal(createMatcher(execution => execution.name === 'z').test({ name: 'z' }), true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('rejects an empty string and an unsupported form', () => {
|
|
41
|
+
assert.throws(() => createMatcher(''), TypeError)
|
|
42
|
+
assert.throws(() => createMatcher(7), TypeError)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('resolve: normalizeMount', () => {
|
|
47
|
+
it('fills every default for a minimal row entry', () => {
|
|
48
|
+
const { ok, mount } = normalizeMount({ tool: 'bash' }, 'config.protect[0]')
|
|
49
|
+
assert.equal(ok, true)
|
|
50
|
+
assert.equal(mount.describe, 'bash')
|
|
51
|
+
assert.deepEqual(mount.options, {
|
|
52
|
+
title: undefined,
|
|
53
|
+
layout: DEFAULT_OPTIONS.layout,
|
|
54
|
+
labels: [],
|
|
55
|
+
buttons: {},
|
|
56
|
+
fields: undefined,
|
|
57
|
+
diff: undefined,
|
|
58
|
+
countdown: null,
|
|
59
|
+
reject: { feedback: false, feedbackPrompt: undefined, requireFeedback: false },
|
|
60
|
+
modify: { mode: 'revise-request' },
|
|
61
|
+
whenUnavailable: 'reject',
|
|
62
|
+
enabled: true,
|
|
63
|
+
maxFieldChars: DEFAULT_OPTIONS.maxFieldChars,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('keeps a valid countdown and normalizes its action', () => {
|
|
68
|
+
const { mount } = normalizeMount({ tool: 'bash', countdown: { seconds: 30 } }, 'src')
|
|
69
|
+
assert.deepEqual(mount.options.countdown, { seconds: 30, action: 'reject', freezeOnInteract: true })
|
|
70
|
+
const notify = normalizeMount({ tool: 'bash', countdown: { seconds: 5, action: 'notify', freezeOnInteract: false } }, 'src')
|
|
71
|
+
assert.deepEqual(notify.mount.options.countdown, { seconds: 5, action: 'notify', freezeOnInteract: false })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('warns and drops an invalid countdown instead of throwing', () => {
|
|
75
|
+
const { ok, mount, warnings } = normalizeMount({ tool: 'bash', countdown: { seconds: 0 } }, 'src')
|
|
76
|
+
assert.equal(ok, true)
|
|
77
|
+
assert.equal(mount.options.countdown, null)
|
|
78
|
+
assert.equal(warnings.length, 1)
|
|
79
|
+
assert.match(warnings[0], /countdown.seconds/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('normalizes field entries and drops invalid ones', () => {
|
|
83
|
+
const { mount, warnings } = normalizeMount({
|
|
84
|
+
tool: 'edit',
|
|
85
|
+
fields: ['file_path', { param: 'content', title: '新内容', render: 'text', editable: true, labels: ['不可撤销'] }, { param: 'x', render: 'nope' }],
|
|
86
|
+
}, 'src')
|
|
87
|
+
assert.deepEqual(mount.options.fields, [
|
|
88
|
+
{ param: 'file_path' },
|
|
89
|
+
{ param: 'content', title: '新内容', render: 'text', editable: true, labels: ['不可撤销'] },
|
|
90
|
+
])
|
|
91
|
+
assert.equal(warnings.length, 1)
|
|
92
|
+
assert.match(warnings[0], /fields\[2\]\.render/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('reports a bad layout, unavailable policy, and modify mode', () => {
|
|
96
|
+
const { mount, warnings } = normalizeMount({
|
|
97
|
+
tool: 'bash', layout: 'grid', whenUnavailable: 'maybe', modify: { mode: 'ignore' }, maxFieldChars: -1, labels: ['ok', 3],
|
|
98
|
+
}, 'src')
|
|
99
|
+
assert.equal(mount.options.layout, 'stacked')
|
|
100
|
+
assert.equal(mount.options.whenUnavailable, 'reject')
|
|
101
|
+
assert.deepEqual(mount.options.modify, { mode: 'revise-request' })
|
|
102
|
+
assert.equal(mount.options.maxFieldChars, DEFAULT_OPTIONS.maxFieldChars)
|
|
103
|
+
assert.deepEqual(mount.options.labels, ['ok'])
|
|
104
|
+
assert.equal(warnings.length, 3)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('fails without a usable matcher', () => {
|
|
108
|
+
assert.equal(normalizeMount({}, 'src').ok, false)
|
|
109
|
+
assert.equal(normalizeMount('bash', 'src').ok, false)
|
|
110
|
+
assert.equal(normalizeMount({ tool: '' }, 'src').ok, false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('accepts the API matcher form', () => {
|
|
114
|
+
const { mount } = normalizeMount({ matcher: /^read/, options: {} }, 'hitl.protect()')
|
|
115
|
+
assert.equal(mount.describe, String(/^read/))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('resolve: normalizeProtectList', () => {
|
|
120
|
+
it('keeps valid rows and reports the rest', () => {
|
|
121
|
+
const { mounts, warnings } = normalizeProtectList([
|
|
122
|
+
{ tool: 'bash' }, { tool: '' }, 'nope', { tool: 'read', countdown: { seconds: -1 } },
|
|
123
|
+
])
|
|
124
|
+
assert.deepEqual(mounts.map(mount => mount.describe), ['bash', 'read'])
|
|
125
|
+
assert.equal(warnings.length, 3)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('warns when the list itself is malformed', () => {
|
|
129
|
+
assert.equal(normalizeProtectList({ tool: 'bash' }).warnings.length, 1)
|
|
130
|
+
assert.deepEqual(normalizeProtectList(undefined), { mounts: [], warnings: [] })
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('resolve: match and applicability', () => {
|
|
135
|
+
it('lets the most recent mount win', () => {
|
|
136
|
+
const first = normalizeMount({ tool: 'bash', title: 'first' }, 'a').mount
|
|
137
|
+
const second = normalizeMount({ tool: 'bash', title: 'second' }, 'b').mount
|
|
138
|
+
assert.equal(matchMount([first, second], { name: 'bash' }).options.title, 'second')
|
|
139
|
+
assert.equal(matchMount([first], { name: 'other' }), undefined)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('honours enabled as a boolean, a predicate, and a throwing predicate', () => {
|
|
143
|
+
const base = normalizeMount({ tool: 'bash' }, 'a').mount
|
|
144
|
+
assert.equal(mountApplies(base, { name: 'bash' }), true)
|
|
145
|
+
assert.equal(mountApplies({ options: { enabled: false } }, {}), false)
|
|
146
|
+
assert.equal(mountApplies({ options: { enabled: execution => execution.name === 'bash' } }, { name: 'bash' }), true)
|
|
147
|
+
assert.equal(mountApplies({ options: { enabled: execution => execution.name === 'bash' } }, { name: 'zsh' }), false)
|
|
148
|
+
assert.equal(mountApplies({ options: { enabled: () => { throw new Error('boom') } } }, {}), false)
|
|
149
|
+
assert.equal(mountApplies({ options: { enabled: 'yes' } }, {}), false)
|
|
150
|
+
assert.equal(mountApplies({ options: {} }, {}), true)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('resolve: defaultEditable', () => {
|
|
155
|
+
it('makes text and markdown editable, everything else read-only', () => {
|
|
156
|
+
assert.equal(defaultEditable('markdown'), true)
|
|
157
|
+
assert.equal(defaultEditable('text'), true)
|
|
158
|
+
assert.equal(defaultEditable('diff'), false)
|
|
159
|
+
assert.equal(defaultEditable('json'), false)
|
|
160
|
+
assert.equal(defaultEditable('hidden'), false)
|
|
161
|
+
})
|
|
162
|
+
})
|