switchroom 0.18.11 → 0.18.13
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/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -14,9 +14,16 @@ import {
|
|
|
14
14
|
AGENT_CALLBACK_PREFIX,
|
|
15
15
|
AGENT_CALLBACK_DATA_MAX,
|
|
16
16
|
wrapAgentCallbacks,
|
|
17
|
+
redactAgentKeyboard,
|
|
17
18
|
parseAgentCallback,
|
|
18
19
|
validateAndWrapAgentKeyboard,
|
|
20
|
+
extractAgentButtonMeta,
|
|
21
|
+
buildButtonConfirmation,
|
|
22
|
+
resolveTapAnnotation,
|
|
23
|
+
escapeHtmlEntities,
|
|
24
|
+
applyTapAnnotationEdit,
|
|
19
25
|
} from '../inline-keyboard-callbacks.js'
|
|
26
|
+
import { redact } from '../secret-detect/redact.js'
|
|
20
27
|
|
|
21
28
|
describe('inline-keyboard-callbacks (#271)', () => {
|
|
22
29
|
describe('AGENT_CALLBACK_DATA_MAX', () => {
|
|
@@ -147,4 +154,445 @@ describe('inline-keyboard-callbacks (#271)', () => {
|
|
|
147
154
|
}
|
|
148
155
|
})
|
|
149
156
|
})
|
|
157
|
+
|
|
158
|
+
// ─── #789 button-choice-confirmation ("✅ You chose: X") ────────────────
|
|
159
|
+
describe('inline_keyboard_confirm meta plumbing (#789)', () => {
|
|
160
|
+
it('captures inline_keyboard_confirm into the per-button meta map', () => {
|
|
161
|
+
const meta = extractAgentButtonMeta([
|
|
162
|
+
[{ text: 'Approve', callback_data: 'ok', inline_keyboard_confirm: true }],
|
|
163
|
+
[{ text: 'Hold', callback_data: 'hold', inline_keyboard_confirm: false }],
|
|
164
|
+
])
|
|
165
|
+
expect(meta.get('ok')).toEqual({ inline_keyboard_confirm: true })
|
|
166
|
+
expect(meta.get('hold')).toEqual({ inline_keyboard_confirm: false })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('strips inline_keyboard_confirm from the wrapped Telegram payload', () => {
|
|
170
|
+
const wrapped = wrapAgentCallbacks([
|
|
171
|
+
[{ text: 'Approve', callback_data: 'ok', inline_keyboard_confirm: true }],
|
|
172
|
+
])
|
|
173
|
+
expect(wrapped[0]![0]).toEqual({ text: 'Approve', callback_data: 'agent:ok' })
|
|
174
|
+
expect('inline_keyboard_confirm' in wrapped[0]![0]!).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('buildButtonConfirmation (#789)', () => {
|
|
179
|
+
// Fixed clock: 14:05 local. Use a UTC-explicit date and read via the
|
|
180
|
+
// matching timezone accessor so the assertion is host-TZ independent.
|
|
181
|
+
const fixedUtc = new Date('2026-07-11T14:05:00Z')
|
|
182
|
+
|
|
183
|
+
it('renders the default template with a bold, escaped label + HH:MM (utc)', () => {
|
|
184
|
+
const line = buildButtonConfirmation({
|
|
185
|
+
template: '✅ You chose: {label} · {time}',
|
|
186
|
+
label: 'Approve',
|
|
187
|
+
timezone: 'utc',
|
|
188
|
+
escapeLabel: (s) => s,
|
|
189
|
+
now: fixedUtc,
|
|
190
|
+
})
|
|
191
|
+
expect(line).toBe('✅ You chose: <b>Approve</b> · 14:05')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('collapses newlines in the label to a single line', () => {
|
|
195
|
+
const line = buildButtonConfirmation({
|
|
196
|
+
template: '✅ You chose: {label} · {time}',
|
|
197
|
+
label: 'Line one\nLine two',
|
|
198
|
+
timezone: 'utc',
|
|
199
|
+
escapeLabel: (s) => s,
|
|
200
|
+
now: fixedUtc,
|
|
201
|
+
})
|
|
202
|
+
expect(line).toBe('✅ You chose: <b>Line one Line two</b> · 14:05')
|
|
203
|
+
expect(line).not.toContain('\n')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('trims a long label to 60 chars and applies the injected escaper', () => {
|
|
207
|
+
const line = buildButtonConfirmation({
|
|
208
|
+
template: '{label}',
|
|
209
|
+
label: 'x'.repeat(80),
|
|
210
|
+
timezone: 'utc',
|
|
211
|
+
escapeLabel: (s) => `[esc:${s.length}]`,
|
|
212
|
+
now: fixedUtc,
|
|
213
|
+
})
|
|
214
|
+
expect(line).toBe('<b>[esc:60]</b>')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('resolveTapAnnotation (#789)', () => {
|
|
219
|
+
const escapeLabel = (s: string) => s
|
|
220
|
+
const now = new Date('2026-07-11T09:30:00Z')
|
|
221
|
+
const base = {
|
|
222
|
+
escapeLabel,
|
|
223
|
+
now,
|
|
224
|
+
parseMode: 'html' as const,
|
|
225
|
+
singleUse: true,
|
|
226
|
+
sourceText: 'Deploy to prod?',
|
|
227
|
+
label: 'Approve',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('annotates when the agent default is ON and the keyboard is single-use', () => {
|
|
231
|
+
const r = resolveTapAnnotation({
|
|
232
|
+
...base,
|
|
233
|
+
config: { enabled: true, timezone: 'utc' },
|
|
234
|
+
})
|
|
235
|
+
expect(r.annotate).toBe(true)
|
|
236
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
237
|
+
expect(r.warnParseMode).toBe(false)
|
|
238
|
+
expect(r.warnSingleUseMismatch).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('per-message inline_keyboard_confirm:false overrides an ON agent default', () => {
|
|
242
|
+
const r = resolveTapAnnotation({
|
|
243
|
+
...base,
|
|
244
|
+
config: { enabled: true, timezone: 'utc' },
|
|
245
|
+
perMessageOverride: false,
|
|
246
|
+
})
|
|
247
|
+
expect(r.annotate).toBe(false)
|
|
248
|
+
expect(r.text).toBeUndefined()
|
|
249
|
+
expect(r.warnParseMode).toBe(false)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('per-message inline_keyboard_confirm:true fires even when the default is OFF', () => {
|
|
253
|
+
const r = resolveTapAnnotation({
|
|
254
|
+
...base,
|
|
255
|
+
config: { enabled: false, timezone: 'utc' },
|
|
256
|
+
perMessageOverride: true,
|
|
257
|
+
})
|
|
258
|
+
expect(r.annotate).toBe(true)
|
|
259
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('replaces (not duplicates) a prior annotation on retap', () => {
|
|
263
|
+
const priorBody = 'Deploy to prod?\n\n✅ You chose: <b>Hold</b> · 08:15'
|
|
264
|
+
const r = resolveTapAnnotation({
|
|
265
|
+
...base,
|
|
266
|
+
sourceText: priorBody,
|
|
267
|
+
config: { enabled: true, timezone: 'utc' },
|
|
268
|
+
})
|
|
269
|
+
expect(r.annotate).toBe(true)
|
|
270
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
271
|
+
// Exactly one confirmation line survives.
|
|
272
|
+
expect((r.text!.match(/✅ You chose:/g) ?? []).length).toBe(1)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('strips a prior annotation whose label carried a stray newline (dotAll)', () => {
|
|
276
|
+
const priorBody = 'Deploy to prod?\n\n✅ You chose: <b>Hold\nnow</b> · 08:15'
|
|
277
|
+
const r = resolveTapAnnotation({
|
|
278
|
+
...base,
|
|
279
|
+
sourceText: priorBody,
|
|
280
|
+
config: { enabled: true, timezone: 'utc' },
|
|
281
|
+
})
|
|
282
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('skips annotation + flags parseMode warning on a non-html parse mode', () => {
|
|
286
|
+
const r = resolveTapAnnotation({
|
|
287
|
+
...base,
|
|
288
|
+
parseMode: 'markdownv2',
|
|
289
|
+
config: { enabled: true, timezone: 'utc' },
|
|
290
|
+
})
|
|
291
|
+
expect(r.annotate).toBe(false)
|
|
292
|
+
expect(r.warnParseMode).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('never annotates a re-tappable (single_use:false) keyboard, flags mismatch', () => {
|
|
296
|
+
const r = resolveTapAnnotation({
|
|
297
|
+
...base,
|
|
298
|
+
singleUse: false,
|
|
299
|
+
config: { enabled: true, timezone: 'utc' },
|
|
300
|
+
})
|
|
301
|
+
expect(r.annotate).toBe(false)
|
|
302
|
+
expect(r.warnSingleUseMismatch).toBe(true)
|
|
303
|
+
// No parseMode warning — we bail on single-use before the parse gate.
|
|
304
|
+
expect(r.warnParseMode).toBe(false)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('skips annotation when the source message has no text (caption-only)', () => {
|
|
308
|
+
const r = resolveTapAnnotation({
|
|
309
|
+
...base,
|
|
310
|
+
sourceText: undefined,
|
|
311
|
+
config: { enabled: true, timezone: 'utc' },
|
|
312
|
+
})
|
|
313
|
+
expect(r.annotate).toBe(false)
|
|
314
|
+
expect(r.warnParseMode).toBe(false)
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// ─── real HTML-entity escaping (#789 round-2 blocker) ────────────────────
|
|
319
|
+
describe('escapeHtmlEntities + real-escaper wiring (#789)', () => {
|
|
320
|
+
const now = new Date('2026-07-11T09:30:00Z')
|
|
321
|
+
|
|
322
|
+
it('escapes exactly &, <, > — and nothing else (no markdown garbling)', () => {
|
|
323
|
+
expect(escapeHtmlEntities('a & b < c > d')).toBe('a & b < c > d')
|
|
324
|
+
// The GFM escaper would turn Do_it into Do\_it; the HTML escaper must not.
|
|
325
|
+
expect(escapeHtmlEntities('Do_it `now` *bold*')).toBe('Do_it `now` *bold*')
|
|
326
|
+
// & first: no double-escaping of produced entities.
|
|
327
|
+
expect(escapeHtmlEntities('<')).toBe('&lt;')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('is the DEFAULT escaper: labels with & < > render Telegram-parseable HTML', () => {
|
|
331
|
+
const line = buildButtonConfirmation({
|
|
332
|
+
template: '✅ You chose: {label} · {time}',
|
|
333
|
+
label: 'Yes & <no>',
|
|
334
|
+
timezone: 'utc',
|
|
335
|
+
now,
|
|
336
|
+
})
|
|
337
|
+
expect(line).toBe('✅ You chose: <b>Yes & <no></b> · 09:30')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('escapes the BASE source text too, not just the label', () => {
|
|
341
|
+
const r = resolveTapAnnotation({
|
|
342
|
+
parseMode: 'html',
|
|
343
|
+
singleUse: true,
|
|
344
|
+
sourceText: 'Deploy <feature/x> & friends?',
|
|
345
|
+
label: 'Approve',
|
|
346
|
+
config: { enabled: true, timezone: 'utc' },
|
|
347
|
+
now,
|
|
348
|
+
})
|
|
349
|
+
expect(r.annotate).toBe(true)
|
|
350
|
+
expect(r.text).toBe(
|
|
351
|
+
'Deploy <feature/x> & friends?\n\n✅ You chose: <b>Approve</b> · 09:30',
|
|
352
|
+
)
|
|
353
|
+
// Only the tags WE emit remain — no raw user-supplied angle brackets.
|
|
354
|
+
expect(r.text!.replace(/<\/?b>/g, '')).not.toMatch(/[<>]/)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('retap dedup matches the annotation as Telegram returns it (plain text, no <b> tags)', () => {
|
|
358
|
+
// Telegram's message.text is plain text — entities are separate — so a
|
|
359
|
+
// real retap sees the prior line WITHOUT tags.
|
|
360
|
+
const r = resolveTapAnnotation({
|
|
361
|
+
parseMode: 'html',
|
|
362
|
+
singleUse: true,
|
|
363
|
+
sourceText: 'Deploy to prod?\n\n✅ You chose: Hold · 08:15',
|
|
364
|
+
label: 'Approve',
|
|
365
|
+
config: { enabled: true, timezone: 'utc' },
|
|
366
|
+
now,
|
|
367
|
+
})
|
|
368
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
369
|
+
expect((r.text!.match(/✅ You chose:/g) ?? []).length).toBe(1)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('String.replace special patterns ($&, $\') in labels are inert', () => {
|
|
373
|
+
const line = buildButtonConfirmation({
|
|
374
|
+
template: '✅ You chose: {label} · {time}',
|
|
375
|
+
label: "$& $' $` $1",
|
|
376
|
+
timezone: 'utc',
|
|
377
|
+
now,
|
|
378
|
+
})
|
|
379
|
+
expect(line).toBe("✅ You chose: <b>$& $' $` $1</b> · 09:30")
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// ─── keyboard-strip fallback when the annotate edit rejects (#789) ───────
|
|
384
|
+
describe('applyTapAnnotationEdit (#789)', () => {
|
|
385
|
+
it('annotates via a single editMessageText call on success', async () => {
|
|
386
|
+
const calls: string[] = []
|
|
387
|
+
const outcome = await applyTapAnnotationEdit(
|
|
388
|
+
{
|
|
389
|
+
editMessageText: async (text, other) => {
|
|
390
|
+
calls.push(`text:${text}:${other.parse_mode}`)
|
|
391
|
+
expect(other.reply_markup).toEqual({ inline_keyboard: [] })
|
|
392
|
+
},
|
|
393
|
+
editMessageReplyMarkup: async () => {
|
|
394
|
+
calls.push('markup')
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
'body\n\n✅ You chose: <b>Approve</b> · 09:30',
|
|
398
|
+
)
|
|
399
|
+
expect(outcome).toBe('annotated')
|
|
400
|
+
expect(calls).toEqual(['text:body\n\n✅ You chose: <b>Approve</b> · 09:30:HTML'])
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('strips the keyboard via editMessageReplyMarkup when the edit rejects', async () => {
|
|
404
|
+
const calls: unknown[] = []
|
|
405
|
+
const outcome = await applyTapAnnotationEdit(
|
|
406
|
+
{
|
|
407
|
+
editMessageText: async () => {
|
|
408
|
+
throw new Error('400: Bad Request: can\'t parse entities')
|
|
409
|
+
},
|
|
410
|
+
editMessageReplyMarkup: async other => {
|
|
411
|
+
calls.push(other)
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
'whatever',
|
|
415
|
+
)
|
|
416
|
+
expect(outcome).toBe('stripped-fallback')
|
|
417
|
+
// Single-use protection held: the keyboard WAS stripped.
|
|
418
|
+
expect(calls).toEqual([{ reply_markup: { inline_keyboard: [] } }])
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('reports failed (without throwing) when both edits reject', async () => {
|
|
422
|
+
const outcome = await applyTapAnnotationEdit(
|
|
423
|
+
{
|
|
424
|
+
editMessageText: async () => {
|
|
425
|
+
throw new Error('400')
|
|
426
|
+
},
|
|
427
|
+
editMessageReplyMarkup: async () => {
|
|
428
|
+
throw new Error('400')
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
'whatever',
|
|
432
|
+
)
|
|
433
|
+
expect(outcome).toBe('failed')
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
// ─── #3148 fast-follow: agent-authored keyboard secret scrub ──────────────
|
|
438
|
+
//
|
|
439
|
+
// wrapAgentCallbacks rewrites ONLY callback_data; the visible `text` label,
|
|
440
|
+
// the `ack_text` toast, and any `copy_text.text` clipboard payload passed
|
|
441
|
+
// through VERBATIM before this fix — so a secret an agent placed in any of
|
|
442
|
+
// them transmitted to Telegram unmasked and resurfaced on tap (echo, toast,
|
|
443
|
+
// "✅ You chose" annotation). redactAgentKeyboard masks those three fields at
|
|
444
|
+
// the outbound boundary using the SAME redact() the reply body uses, while
|
|
445
|
+
// leaving the routing key (callback_data) exact.
|
|
446
|
+
describe('redactAgentKeyboard (#3148 fast-follow)', () => {
|
|
447
|
+
// Assemble the fixture at runtime so this source file never carries a
|
|
448
|
+
// contiguous token that trips push-protection / secretlint (CLAUDE.md).
|
|
449
|
+
const GITHUB_PAT = 'ghp' + '_' + '16C7e42F292c6912E7710c838347Ae178B4a'
|
|
450
|
+
|
|
451
|
+
it('masks a secret in a button `text` label in the sent payload (real redact)', () => {
|
|
452
|
+
const raw = [
|
|
453
|
+
[{ text: `Use ${GITHUB_PAT}`, callback_data: 'use_token' }],
|
|
454
|
+
]
|
|
455
|
+
// Mirror the gateway pipeline: redact BEFORE wrap.
|
|
456
|
+
const wrapped = wrapAgentCallbacks(redactAgentKeyboard(raw, redact))
|
|
457
|
+
const btn = wrapped[0]![0]!
|
|
458
|
+
// OUTCOME: the label bytes actually shipped to Telegram carry no secret.
|
|
459
|
+
expect(btn.text as string).not.toContain(GITHUB_PAT)
|
|
460
|
+
expect(btn.text as string).toContain('[REDACTED')
|
|
461
|
+
expect((btn.text as string).length).toBeGreaterThan(0)
|
|
462
|
+
// Routing key untouched (only the agent: namespace prefix was added).
|
|
463
|
+
expect(btn.callback_data).toBe(`${AGENT_CALLBACK_PREFIX}use_token`)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('masks a secret in `ack_text` in the extracted per-button meta (real redact)', () => {
|
|
467
|
+
const raw = [
|
|
468
|
+
[{ text: 'Deploy', callback_data: 'deploy', ack_text: `token ${GITHUB_PAT}` }],
|
|
469
|
+
]
|
|
470
|
+
// Mirror the gateway: extract meta from the REDACTED keyboard so the
|
|
471
|
+
// stashed toast (shown via answerCallbackQuery on tap) is masked.
|
|
472
|
+
const meta = extractAgentButtonMeta(redactAgentKeyboard(raw, redact))
|
|
473
|
+
const ack = meta.get('deploy')!.ack_text!
|
|
474
|
+
expect(ack).not.toContain(GITHUB_PAT)
|
|
475
|
+
expect(ack).toContain('[REDACTED')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('masks a secret in `copy_text.text` clipboard payload (real redact)', () => {
|
|
479
|
+
const raw = [
|
|
480
|
+
[{ text: 'Copy token', callback_data: 'copy', copy_text: { text: GITHUB_PAT } }],
|
|
481
|
+
]
|
|
482
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
483
|
+
const ct = (btn as { copy_text: { text: string } }).copy_text.text
|
|
484
|
+
expect(ct).not.toContain(GITHUB_PAT)
|
|
485
|
+
expect(ct).toContain('[REDACTED')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('never empties a label that is ENTIRELY a secret (non-empty invariant)', () => {
|
|
489
|
+
const raw = [[{ text: GITHUB_PAT, callback_data: 'x' }]]
|
|
490
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
491
|
+
expect((btn.text as string).length).toBeGreaterThan(0)
|
|
492
|
+
expect(btn.text as string).not.toContain(GITHUB_PAT)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('leaves a legit (secret-free) label unchanged', () => {
|
|
496
|
+
const raw = [
|
|
497
|
+
[{ text: 'Approve PR #547', callback_data: 'approve', ack_text: 'Approved ✓' }],
|
|
498
|
+
[{ text: 'Hold', callback_data: 'hold' }],
|
|
499
|
+
]
|
|
500
|
+
const out = redactAgentKeyboard(raw, redact)
|
|
501
|
+
expect(out[0]![0]!.text).toBe('Approve PR #547')
|
|
502
|
+
expect(out[0]![0]!.ack_text).toBe('Approved ✓')
|
|
503
|
+
expect(out[1]![0]!.text).toBe('Hold')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('NEVER passes callback_data through the redactor (routing key stays exact)', () => {
|
|
507
|
+
// A fake redactor that mangles everything it sees; anything the routing
|
|
508
|
+
// key touched would be corrupted. Proves copy_text/url/callback_data are
|
|
509
|
+
// handled correctly by field, not blanket-scrubbed.
|
|
510
|
+
const seen: string[] = []
|
|
511
|
+
const mangle = (s: string) => {
|
|
512
|
+
seen.push(s)
|
|
513
|
+
return `X${s}X`
|
|
514
|
+
}
|
|
515
|
+
const raw = [
|
|
516
|
+
[
|
|
517
|
+
{ text: 'Label', callback_data: 'route_key_do_not_touch', ack_text: 'toast' },
|
|
518
|
+
{ text: 'Link', url: 'https://example.com/keep' },
|
|
519
|
+
{ text: 'Clip', callback_data: 'c2', copy_text: { text: 'secret-clip' } },
|
|
520
|
+
],
|
|
521
|
+
]
|
|
522
|
+
const out = redactAgentKeyboard(raw, mangle)
|
|
523
|
+
// callback_data + url are byte-exact.
|
|
524
|
+
expect(out[0]![0]!.callback_data).toBe('route_key_do_not_touch')
|
|
525
|
+
expect(out[0]![1]!.url).toBe('https://example.com/keep')
|
|
526
|
+
expect(out[0]![2]!.callback_data).toBe('c2')
|
|
527
|
+
// Only free-text fields were routed through the redactor.
|
|
528
|
+
expect(out[0]![0]!.text).toBe('XLabelX')
|
|
529
|
+
expect(out[0]![0]!.ack_text).toBe('XtoastX')
|
|
530
|
+
expect((out[0]![2]! as { copy_text: { text: string } }).copy_text.text).toBe('Xsecret-clipX')
|
|
531
|
+
expect(seen).toEqual(['Label', 'toast', 'Link', 'Clip', 'secret-clip'])
|
|
532
|
+
expect(seen).not.toContain('route_key_do_not_touch')
|
|
533
|
+
expect(seen).not.toContain('https://example.com/keep')
|
|
534
|
+
expect(seen).not.toContain('c2')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('masks a secret in switch_inline_query* (pasted into the chat input on tap)', () => {
|
|
538
|
+
const raw = [
|
|
539
|
+
[{ text: 'Share', callback_data: 's', switch_inline_query: `key ${GITHUB_PAT}` }],
|
|
540
|
+
[{
|
|
541
|
+
text: 'Fill here',
|
|
542
|
+
callback_data: 'f',
|
|
543
|
+
switch_inline_query_current_chat: `key ${GITHUB_PAT}`,
|
|
544
|
+
}],
|
|
545
|
+
]
|
|
546
|
+
const out = redactAgentKeyboard(raw, redact)
|
|
547
|
+
const siq = (out[0]![0]! as { switch_inline_query: string }).switch_inline_query
|
|
548
|
+
const siqc = (out[1]![0]! as { switch_inline_query_current_chat: string })
|
|
549
|
+
.switch_inline_query_current_chat
|
|
550
|
+
expect(siq).not.toContain(GITHUB_PAT)
|
|
551
|
+
expect(siq).toContain('[REDACTED')
|
|
552
|
+
expect(siqc).not.toContain(GITHUB_PAT)
|
|
553
|
+
expect(siqc).toContain('[REDACTED')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('masks a secret in switch_inline_query_chosen_chat.query', () => {
|
|
557
|
+
const raw = [[{
|
|
558
|
+
text: 'Share to…',
|
|
559
|
+
callback_data: 'cc',
|
|
560
|
+
switch_inline_query_chosen_chat: { query: `key ${GITHUB_PAT}`, allow_user_chats: true },
|
|
561
|
+
}]]
|
|
562
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
563
|
+
const cc = (btn as { switch_inline_query_chosen_chat: { query: string; allow_user_chats?: boolean } })
|
|
564
|
+
.switch_inline_query_chosen_chat
|
|
565
|
+
expect(cc.query).not.toContain(GITHUB_PAT)
|
|
566
|
+
expect(cc.query).toContain('[REDACTED')
|
|
567
|
+
// sibling sub-fields are preserved
|
|
568
|
+
expect(cc.allow_user_chats).toBe(true)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('clamps a masked field that the marker pushed over the Telegram cap (reply not dropped)', () => {
|
|
572
|
+
// A 60-char label (< 64 cap) whose short secret expands under the marker
|
|
573
|
+
// would exceed 64 and 400-drop the whole reply; the clamp keeps it ≤ cap.
|
|
574
|
+
const shortSecret = 'AKIAIOSFODNN7EXAMPLE' // 20 chars, AWS-key shaped
|
|
575
|
+
const label = `${shortSecret} ${'x'.repeat(43)}` // 64 chars total, at the cap
|
|
576
|
+
const raw = [[{ text: label, callback_data: 'x' }]]
|
|
577
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
578
|
+
const text = btn.text as string
|
|
579
|
+
expect(text.length).toBeLessThanOrEqual(64)
|
|
580
|
+
expect(text).not.toContain(shortSecret)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('preserves keyboard structure, row/column order, and does not mutate input', () => {
|
|
584
|
+
const raw = [
|
|
585
|
+
[{ text: 'A', callback_data: 'a' }, { text: 'B', callback_data: 'b' }],
|
|
586
|
+
[{ text: 'C', callback_data: 'c' }],
|
|
587
|
+
]
|
|
588
|
+
const out = redactAgentKeyboard(raw, (s) => s)
|
|
589
|
+
expect(out.map((r) => r.map((b) => b.callback_data))).toEqual([
|
|
590
|
+
['a', 'b'],
|
|
591
|
+
['c'],
|
|
592
|
+
])
|
|
593
|
+
// Fresh objects — input untouched.
|
|
594
|
+
expect(out[0]![0]).not.toBe(raw[0]![0])
|
|
595
|
+
expect(raw[0]![0]!.text).toBe('A')
|
|
596
|
+
})
|
|
597
|
+
})
|
|
150
598
|
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2975 Stage 2 — gateway-side IPC surface for `check_pre_approved`, the
|
|
3
|
+
* read-only pre-approval query hostd sends before applying the
|
|
4
|
+
* `config_propose_edit` rate limit.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the REAL createIpcServer over a real (tmp) unix socket with a raw
|
|
7
|
+
* `net.createConnection` client — the same hostd → gateway approval-gateway
|
|
8
|
+
* transport. Pins:
|
|
9
|
+
* - wire-shape validation (validateClientMessage);
|
|
10
|
+
* - the wired handler is invoked and its `pre_approved_result` relayed;
|
|
11
|
+
* - FAIL CLOSED: no handler wired (old gateway / mixed-version) → the server
|
|
12
|
+
* itself replies `preApproved: false`.
|
|
13
|
+
*
|
|
14
|
+
* The message is hostd → gateway over the approval-gateway socket; it is NOT
|
|
15
|
+
* reachable via any agent-facing hostd verb (there is no hostd op that emits
|
|
16
|
+
* it), and the handler treats it as read-only.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
19
|
+
import { createConnection } from "node:net";
|
|
20
|
+
import { mkdtempSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import {
|
|
24
|
+
createIpcServer,
|
|
25
|
+
validateClientMessage,
|
|
26
|
+
type IpcServer,
|
|
27
|
+
type IpcClient,
|
|
28
|
+
} from "../gateway/ipc-server.js";
|
|
29
|
+
import type { CheckPreApprovedMessage } from "../gateway/ipc-protocol.js";
|
|
30
|
+
|
|
31
|
+
function tmpSocket(): string {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "ipc-cpa-test-"));
|
|
33
|
+
return join(dir, "test.sock");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wait(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
function queryOnce(
|
|
42
|
+
socketPath: string,
|
|
43
|
+
agentName: string,
|
|
44
|
+
correlationId: string,
|
|
45
|
+
unifiedDiff: string,
|
|
46
|
+
): Promise<any> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const sock = createConnection(socketPath);
|
|
49
|
+
let buffer = "";
|
|
50
|
+
sock.on("connect", () => {
|
|
51
|
+
sock.write(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
type: "check_pre_approved",
|
|
54
|
+
agentName,
|
|
55
|
+
correlationId,
|
|
56
|
+
unifiedDiff,
|
|
57
|
+
} as CheckPreApprovedMessage) + "\n",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
sock.on("data", (chunk) => {
|
|
61
|
+
buffer += chunk.toString("utf8");
|
|
62
|
+
const idx = buffer.indexOf("\n");
|
|
63
|
+
if (idx >= 0) {
|
|
64
|
+
sock.end();
|
|
65
|
+
resolve(JSON.parse(buffer.slice(0, idx)));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
sock.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("validateClientMessage — check_pre_approved (#2975 Stage 2)", () => {
|
|
73
|
+
it("accepts a well-formed query", () => {
|
|
74
|
+
expect(
|
|
75
|
+
validateClientMessage({
|
|
76
|
+
type: "check_pre_approved",
|
|
77
|
+
agentName: "klanker",
|
|
78
|
+
correlationId: "abc",
|
|
79
|
+
unifiedDiff: "--- a\n+++ b\n",
|
|
80
|
+
}),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects missing/malformed agentName", () => {
|
|
85
|
+
expect(
|
|
86
|
+
validateClientMessage({ type: "check_pre_approved", correlationId: "abc", unifiedDiff: "x" }),
|
|
87
|
+
).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "Bad Name", correlationId: "abc", unifiedDiff: "x" }),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects missing/oversized correlationId", () => {
|
|
94
|
+
expect(
|
|
95
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", unifiedDiff: "x" }),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
expect(
|
|
98
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "", unifiedDiff: "x" }),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
expect(
|
|
101
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "x".repeat(65), unifiedDiff: "x" }),
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects an empty/missing unifiedDiff", () => {
|
|
106
|
+
expect(
|
|
107
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "abc" }),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
expect(
|
|
110
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "abc", unifiedDiff: "" }),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("ipc-server — onCheckPreApproved handler (#2975 Stage 2)", () => {
|
|
116
|
+
const servers: IpcServer[] = [];
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
for (const s of servers) await s.close();
|
|
120
|
+
servers.length = 0;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails CLOSED (preApproved:false) when no handler is wired — mixed-version-safe default", async () => {
|
|
124
|
+
const socketPath = tmpSocket();
|
|
125
|
+
const server = createIpcServer({
|
|
126
|
+
socketPath,
|
|
127
|
+
onClientRegistered: () => {},
|
|
128
|
+
onClientDisconnected: () => {},
|
|
129
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
130
|
+
onSessionEvent: () => {},
|
|
131
|
+
onPermissionRequest: () => {},
|
|
132
|
+
onHeartbeat: () => {},
|
|
133
|
+
onScheduleRestart: () => {},
|
|
134
|
+
// onCheckPreApproved intentionally omitted.
|
|
135
|
+
});
|
|
136
|
+
servers.push(server);
|
|
137
|
+
await wait(50);
|
|
138
|
+
|
|
139
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-1", "--- a\n+++ b\n");
|
|
140
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-1", preApproved: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("invokes the wired handler with the query, and relays its reply", async () => {
|
|
144
|
+
const socketPath = tmpSocket();
|
|
145
|
+
let received: CheckPreApprovedMessage | null = null;
|
|
146
|
+
const server = createIpcServer({
|
|
147
|
+
socketPath,
|
|
148
|
+
onClientRegistered: () => {},
|
|
149
|
+
onClientDisconnected: () => {},
|
|
150
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
151
|
+
onSessionEvent: () => {},
|
|
152
|
+
onPermissionRequest: () => {},
|
|
153
|
+
onHeartbeat: () => {},
|
|
154
|
+
onScheduleRestart: () => {},
|
|
155
|
+
onCheckPreApproved: (client: IpcClient, msg: CheckPreApprovedMessage) => {
|
|
156
|
+
received = msg;
|
|
157
|
+
client.send({ type: "pre_approved_result", correlationId: msg.correlationId, preApproved: true });
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
servers.push(server);
|
|
161
|
+
await wait(50);
|
|
162
|
+
|
|
163
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-2", "--- a\n+++ b\n");
|
|
164
|
+
expect(received).toEqual({
|
|
165
|
+
type: "check_pre_approved",
|
|
166
|
+
agentName: "klanker",
|
|
167
|
+
correlationId: "corr-2",
|
|
168
|
+
unifiedDiff: "--- a\n+++ b\n",
|
|
169
|
+
});
|
|
170
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-2", preApproved: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("relays preApproved:false from a wired handler", async () => {
|
|
174
|
+
const socketPath = tmpSocket();
|
|
175
|
+
const server = createIpcServer({
|
|
176
|
+
socketPath,
|
|
177
|
+
onClientRegistered: () => {},
|
|
178
|
+
onClientDisconnected: () => {},
|
|
179
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
180
|
+
onSessionEvent: () => {},
|
|
181
|
+
onPermissionRequest: () => {},
|
|
182
|
+
onHeartbeat: () => {},
|
|
183
|
+
onScheduleRestart: () => {},
|
|
184
|
+
onCheckPreApproved: (client: IpcClient, msg: CheckPreApprovedMessage) => {
|
|
185
|
+
client.send({ type: "pre_approved_result", correlationId: msg.correlationId, preApproved: false });
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
servers.push(server);
|
|
189
|
+
await wait(50);
|
|
190
|
+
|
|
191
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-3", "--- a\n+++ b\n");
|
|
192
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-3", preApproved: false });
|
|
193
|
+
});
|
|
194
|
+
});
|