switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -51,19 +51,6 @@ function makeDeps(agentName: string | null) {
|
|
|
51
51
|
['chat1:thr1:msg1', 100],
|
|
52
52
|
['chat2:thr2:msg2', 200],
|
|
53
53
|
])
|
|
54
|
-
// PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
|
|
55
|
-
// healthy registered-disconnect scenario both maps would carry the
|
|
56
|
-
// same keys (delivery succeeded); the dangling-sweep tests below
|
|
57
|
-
// override individual deps to exercise the orphaned-key path.
|
|
58
|
-
const claudeBusyKeys = new Set<string>([
|
|
59
|
-
'chat1:thr1:msg1',
|
|
60
|
-
'chat2:thr2:msg2',
|
|
61
|
-
])
|
|
62
|
-
// #2787: shadow timestamp map, kept in lockstep with claudeBusyKeys.
|
|
63
|
-
const claudeBusyKeySince = new Map<string, number>([
|
|
64
|
-
['chat1:thr1:msg1', 100],
|
|
65
|
-
['chat2:thr2:msg2', 200],
|
|
66
|
-
])
|
|
67
54
|
const activeDraftStreams = new Map<string, FakeStream>([
|
|
68
55
|
['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
|
|
69
56
|
['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
|
|
@@ -79,8 +66,6 @@ function makeDeps(agentName: string | null) {
|
|
|
79
66
|
activeStatusReactions,
|
|
80
67
|
activeReactionMsgIds,
|
|
81
68
|
activeTurnStartedAt,
|
|
82
|
-
claudeBusyKeys,
|
|
83
|
-
claudeBusyKeySince,
|
|
84
69
|
activeDraftStreams,
|
|
85
70
|
clearActiveReactions,
|
|
86
71
|
disposeProgressDriver,
|
|
@@ -202,8 +187,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
202
187
|
['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
|
|
203
188
|
]),
|
|
204
189
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
205
|
-
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
206
|
-
claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
207
190
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
208
191
|
clearActiveReactions,
|
|
209
192
|
disposeProgressDriver,
|
|
@@ -214,10 +197,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
214
197
|
|
|
215
198
|
flushOnAgentDisconnect(deps)
|
|
216
199
|
|
|
217
|
-
// PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
|
|
218
|
-
// dangling entry — both maps mirror each other on registered
|
|
219
|
-
// disconnects, so a key in one is always a key in the other.
|
|
220
|
-
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
221
200
|
// The sweep fired and cleared the dangling entry.
|
|
222
201
|
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
223
202
|
expect(deps.activeReactionMsgIds.size).toBe(0)
|
|
@@ -261,8 +240,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
261
240
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
262
241
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
263
242
|
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
264
|
-
claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
|
|
265
|
-
claudeBusyKeySince: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
266
243
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
267
244
|
clearActiveReactions: vi.fn(),
|
|
268
245
|
disposeProgressDriver: vi.fn(),
|
|
@@ -278,109 +255,11 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
278
255
|
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
279
256
|
})
|
|
280
257
|
|
|
281
|
-
// PR3b orphan-sweep
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
// persists in claudeBusyKeys → fleet gate wedges. This test pins
|
|
287
|
-
// the post-sweep claudeBusyKeys.clear() fix.
|
|
288
|
-
it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
|
|
289
|
-
const onDanglingTurnsSwept = vi.fn()
|
|
290
|
-
const log = vi.fn()
|
|
291
|
-
const deps = {
|
|
292
|
-
agentName: 'clerk',
|
|
293
|
-
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
294
|
-
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
295
|
-
activeTurnStartedAt: new Map<string, number>(),
|
|
296
|
-
// The orphan scenario: claude was handed a turn (e.g. cron
|
|
297
|
-
// synthetic delivered), so claudeBusyKeys has it, but
|
|
298
|
-
// activeTurnStartedAt was never set because cron bypasses
|
|
299
|
-
// handleInbound's fresh-turn branch.
|
|
300
|
-
claudeBusyKeys: new Set<string>(['cron-only-key:_']),
|
|
301
|
-
claudeBusyKeySince: new Map<string, number>([['cron-only-key:_', 100]]),
|
|
302
|
-
activeDraftStreams: new Map<string, FakeStream>(),
|
|
303
|
-
clearActiveReactions: vi.fn(),
|
|
304
|
-
disposeProgressDriver: vi.fn(),
|
|
305
|
-
stopTurnTypingLoops: vi.fn(),
|
|
306
|
-
onDanglingTurnsSwept,
|
|
307
|
-
log,
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
flushOnAgentDisconnect(deps)
|
|
311
|
-
|
|
312
|
-
// The orphan is cleared even though it never had an
|
|
313
|
-
// activeTurnStartedAt entry.
|
|
314
|
-
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
315
|
-
// The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
|
|
316
|
-
// that map to sweep) — so onDanglingTurnsSwept shouldn't fire
|
|
317
|
-
// either. The orphan sweep is a separate observation.
|
|
318
|
-
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
319
|
-
// But it logs the orphan-clear so operators can see it.
|
|
320
|
-
expect(
|
|
321
|
-
log.mock.calls.some((c: unknown[]) =>
|
|
322
|
-
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
323
|
-
),
|
|
324
|
-
).toBe(true)
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
it('orphan-sweep singular vs plural log message agrees with count', () => {
|
|
328
|
-
// Tiny grammar regression: "1 entry" vs "2 entries".
|
|
329
|
-
const log = vi.fn()
|
|
330
|
-
const baseDeps = {
|
|
331
|
-
agentName: 'clerk',
|
|
332
|
-
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
333
|
-
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
334
|
-
activeTurnStartedAt: new Map<string, number>(),
|
|
335
|
-
activeDraftStreams: new Map<string, FakeStream>(),
|
|
336
|
-
clearActiveReactions: vi.fn(),
|
|
337
|
-
disposeProgressDriver: vi.fn(),
|
|
338
|
-
stopTurnTypingLoops: vi.fn(),
|
|
339
|
-
}
|
|
340
|
-
// Singular form.
|
|
341
|
-
flushOnAgentDisconnect({
|
|
342
|
-
...baseDeps,
|
|
343
|
-
claudeBusyKeys: new Set<string>(['k1:_']),
|
|
344
|
-
claudeBusyKeySince: new Map<string, number>([['k1:_', 100]]),
|
|
345
|
-
log,
|
|
346
|
-
})
|
|
347
|
-
expect(log.mock.calls.some((c: unknown[]) =>
|
|
348
|
-
typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
|
|
349
|
-
)).toBe(true)
|
|
350
|
-
// Plural form.
|
|
351
|
-
log.mockClear()
|
|
352
|
-
flushOnAgentDisconnect({
|
|
353
|
-
...baseDeps,
|
|
354
|
-
claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
|
|
355
|
-
claudeBusyKeySince: new Map<string, number>([['k1:_', 100], ['k2:1', 100]]),
|
|
356
|
-
log,
|
|
357
|
-
})
|
|
358
|
-
expect(log.mock.calls.some((c: unknown[]) =>
|
|
359
|
-
typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
|
|
360
|
-
)).toBe(true)
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
|
|
364
|
-
// Zero-noise discipline: every disconnect for a healthy idle
|
|
365
|
-
// agent shouldn't produce a "0 orphan claudeBusyKeys" line.
|
|
366
|
-
const log = vi.fn()
|
|
367
|
-
flushOnAgentDisconnect({
|
|
368
|
-
agentName: 'clerk',
|
|
369
|
-
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
370
|
-
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
371
|
-
activeTurnStartedAt: new Map<string, number>(),
|
|
372
|
-
claudeBusyKeys: new Set<string>(),
|
|
373
|
-
claudeBusyKeySince: new Map<string, number>(),
|
|
374
|
-
activeDraftStreams: new Map<string, FakeStream>(),
|
|
375
|
-
clearActiveReactions: vi.fn(),
|
|
376
|
-
disposeProgressDriver: vi.fn(),
|
|
377
|
-
stopTurnTypingLoops: vi.fn(),
|
|
378
|
-
log,
|
|
379
|
-
})
|
|
380
|
-
expect(log.mock.calls.some((c: unknown[]) =>
|
|
381
|
-
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
382
|
-
)).toBe(false)
|
|
383
|
-
})
|
|
258
|
+
// (#2996 P1) The PR3b claudeBusyKeys orphan-sweep tests that lived here
|
|
259
|
+
// were deleted with the set itself — the delivery machine's bridgeDown
|
|
260
|
+
// event (emitted by the gateway alongside this flush) structurally
|
|
261
|
+
// clears the turn-in-flight gate for synthetic-delivered turns that
|
|
262
|
+
// died without a turn_end.
|
|
384
263
|
|
|
385
264
|
it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
|
|
386
265
|
// Backward-compat guard — existing callers that don't pass the new
|
|
@@ -390,8 +269,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
390
269
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
391
270
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
392
271
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
393
|
-
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
394
|
-
claudeBusyKeySince: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
395
272
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
396
273
|
clearActiveReactions: vi.fn(),
|
|
397
274
|
disposeProgressDriver: vi.fn(),
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration-wiring pin for the #2996 P0a leaf move.
|
|
3
|
+
*
|
|
4
|
+
* P0a extracted the 62 top-level grammY registration statements — the 60
|
|
5
|
+
* `bot.use` / `bot.command` / `bot.on` / `bot.catch` calls PLUS the two
|
|
6
|
+
* interleaved handler-registering helpers (`registerOpsInfoCommands` and the
|
|
7
|
+
* terminal `installUnhandledMessageCatchAll`) — out of their previously
|
|
8
|
+
* module-scope-interspersed positions in gateway.ts and into a single
|
|
9
|
+
* `registerGatewayHandlers(bot, deps)` function, called exactly once at the
|
|
10
|
+
* same point in module evaluation as the old last registration.
|
|
11
|
+
*
|
|
12
|
+
* grammY middleware/handler ORDER is load-bearing — the admin-gate `bot.use`
|
|
13
|
+
* must run before any command, and the specific `message:*` handlers before
|
|
14
|
+
* the catch-all. This test is the deterministic guard that the move (and any
|
|
15
|
+
* future edit to the block) preserves the EXACT ordered registration
|
|
16
|
+
* sequence. It is outcome-asserting: it fails if a registration is dropped,
|
|
17
|
+
* reordered, duplicated, or added, and if any registration leaks back out to
|
|
18
|
+
* module top-level (which would change its evaluation order relative to the
|
|
19
|
+
* single call site).
|
|
20
|
+
*
|
|
21
|
+
* P0b (#2996) update: the single `registerGatewayHandlers(bot, {})` call moved
|
|
22
|
+
* OFF module top level into the boot function `initGatewayBot()` — because P0b
|
|
23
|
+
* defers `new Bot` construction to boot, so `bot` no longer exists at import.
|
|
24
|
+
* The call still fires exactly once, after construction and before the runner,
|
|
25
|
+
* preserving registration order. The call-site assertion below now pins that
|
|
26
|
+
* new location (zero top-level calls; exactly one inside initGatewayBot).
|
|
27
|
+
*
|
|
28
|
+
* The golden sequence below was captured from `origin/main` immediately
|
|
29
|
+
* BEFORE the move (parser-extracted top-level `bot.*` order) so the same
|
|
30
|
+
* assertion holds pre- and post-move — the ordered list is a behaviour
|
|
31
|
+
* invariant, not an artifact of the new structure.
|
|
32
|
+
*
|
|
33
|
+
* Source-parsing (not import) mirrors gateway-pending-command-wiring.test.ts:
|
|
34
|
+
* gateway.ts is not import-safe under vitest until the #2996 P0 importability
|
|
35
|
+
* seam lands, so we assert on the parsed source instead.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect } from 'vitest'
|
|
39
|
+
import { readFileSync } from 'node:fs'
|
|
40
|
+
import { fileURLToPath } from 'node:url'
|
|
41
|
+
import { dirname, resolve } from 'node:path'
|
|
42
|
+
import ts from 'typescript'
|
|
43
|
+
|
|
44
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
46
|
+
const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The frozen, ordered registration sequence. Order is significant — this is
|
|
50
|
+
* the exact top-level order captured from origin/main BEFORE the move.
|
|
51
|
+
* Format: `use` / `catch` (no name), `command:<name>`, `on:<event>`,
|
|
52
|
+
* `helper:<fn>` for the interleaved handler-registering helper calls.
|
|
53
|
+
*/
|
|
54
|
+
const GOLDEN_REGISTRATIONS: readonly string[] = [
|
|
55
|
+
'use',
|
|
56
|
+
'helper:registerStartInfoCommands',
|
|
57
|
+
'command:inject',
|
|
58
|
+
'command:compact',
|
|
59
|
+
'command:clear',
|
|
60
|
+
'helper:registerModelEffortCommands',
|
|
61
|
+
'command:agentstart',
|
|
62
|
+
'command:agentstop',
|
|
63
|
+
'command:stop',
|
|
64
|
+
'command:restart',
|
|
65
|
+
'command:new',
|
|
66
|
+
'command:update',
|
|
67
|
+
'command:upgradestatus',
|
|
68
|
+
'command:upgrade',
|
|
69
|
+
'command:audit',
|
|
70
|
+
'command:approve',
|
|
71
|
+
'command:deny',
|
|
72
|
+
'command:folders',
|
|
73
|
+
'command:pending',
|
|
74
|
+
'command:interrupt',
|
|
75
|
+
'command:connect',
|
|
76
|
+
'command:auth',
|
|
77
|
+
'command:vault',
|
|
78
|
+
'command:topics',
|
|
79
|
+
'command:logs',
|
|
80
|
+
'command:memory',
|
|
81
|
+
'command:issues',
|
|
82
|
+
'command:usage',
|
|
83
|
+
'helper:registerOpsInfoCommands',
|
|
84
|
+
'on:callback_query:data',
|
|
85
|
+
'on:message:text',
|
|
86
|
+
'on:message:photo',
|
|
87
|
+
'on:message:document',
|
|
88
|
+
'on:message:voice',
|
|
89
|
+
'on:message:audio',
|
|
90
|
+
'on:message:video',
|
|
91
|
+
'on:message:video_note',
|
|
92
|
+
'on:message:sticker',
|
|
93
|
+
'on:message:animation',
|
|
94
|
+
'on:message:contact',
|
|
95
|
+
'on:message:location',
|
|
96
|
+
'on:message:venue',
|
|
97
|
+
'on:message:poll',
|
|
98
|
+
'on:message:web_app_data',
|
|
99
|
+
'on:message:users_shared',
|
|
100
|
+
'on:message:chat_shared',
|
|
101
|
+
'on:message:dice',
|
|
102
|
+
'on:message:game',
|
|
103
|
+
'on:message:story',
|
|
104
|
+
'on:message:paid_media',
|
|
105
|
+
'on:message:successful_payment',
|
|
106
|
+
'on:message:passport_data',
|
|
107
|
+
'on:message:checklist_tasks_done',
|
|
108
|
+
'on:message:checklist_tasks_added',
|
|
109
|
+
'on:message:pinned_message',
|
|
110
|
+
'helper:installUnhandledMessageCatchAll',
|
|
111
|
+
'on:message_reaction',
|
|
112
|
+
'catch',
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The interleaved handler-registering helper calls. Their position in the
|
|
117
|
+
* sequence is load-bearing: `installUnhandledMessageCatchAll` MUST come after
|
|
118
|
+
* every `message:*` handler (grammY no-double-handling; see
|
|
119
|
+
* catch-all-unhandled-message.test.ts) and before `message_reaction`.
|
|
120
|
+
*/
|
|
121
|
+
const HELPER_REGISTRARS = new Set(['registerOpsInfoCommands', 'registerStartInfoCommands', 'registerModelEffortCommands', 'installUnhandledMessageCatchAll'])
|
|
122
|
+
|
|
123
|
+
const sourceFile = ts.createSourceFile(
|
|
124
|
+
GATEWAY_PATH,
|
|
125
|
+
GATEWAY_SRC,
|
|
126
|
+
ts.ScriptTarget.Latest,
|
|
127
|
+
true,
|
|
128
|
+
ts.ScriptKind.TS,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
/** `bot.<verb>` for a CallExpression whose callee is `bot.<verb>`, else null. */
|
|
132
|
+
function botVerb(expr: ts.Expression): string | null {
|
|
133
|
+
if (!ts.isCallExpression(expr)) return null
|
|
134
|
+
const callee = expr.expression
|
|
135
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
|
|
136
|
+
if (callee.expression.text === 'bot') return callee.name.text
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Unwrap a possibly `as`-cast string literal to its text; else null. */
|
|
142
|
+
function stringArg(arg: ts.Expression | undefined): string | null {
|
|
143
|
+
if (!arg) return null
|
|
144
|
+
let node: ts.Expression = arg
|
|
145
|
+
if (ts.isAsExpression(node)) node = node.expression
|
|
146
|
+
if (ts.isStringLiteralLike(node)) return node.text
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Canonical signature for a registration statement, or null if not one. */
|
|
151
|
+
function registrationSig(stmt: ts.Statement): string | null {
|
|
152
|
+
if (!ts.isExpressionStatement(stmt)) return null
|
|
153
|
+
const expr = stmt.expression
|
|
154
|
+
if (!ts.isCallExpression(expr)) return null
|
|
155
|
+
// Interleaved handler-registering helper: `helperName(bot, ...)`.
|
|
156
|
+
if (ts.isIdentifier(expr.expression) && HELPER_REGISTRARS.has(expr.expression.text)) {
|
|
157
|
+
return `helper:${expr.expression.text}`
|
|
158
|
+
}
|
|
159
|
+
const verb = botVerb(expr)
|
|
160
|
+
if (verb === null) return null
|
|
161
|
+
if (verb === 'command' || verb === 'on') {
|
|
162
|
+
const name = stringArg(expr.arguments[0])
|
|
163
|
+
return name === null ? `${verb}:<non-literal>` : `${verb}:${name}`
|
|
164
|
+
}
|
|
165
|
+
return verb // use / catch / anything else with no name key
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findFunction(name: string): ts.FunctionDeclaration | undefined {
|
|
169
|
+
for (const s of sourceFile.statements) {
|
|
170
|
+
if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
|
|
171
|
+
}
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('gateway #2996 P0a: registerGatewayHandlers is the single ordered registration unit', () => {
|
|
176
|
+
it('defines registerGatewayHandlers(bot, deps) at module top level', () => {
|
|
177
|
+
const fn = findFunction('registerGatewayHandlers')
|
|
178
|
+
expect(fn).toBeDefined()
|
|
179
|
+
expect(fn!.parameters.length).toBe(2)
|
|
180
|
+
expect(fn!.parameters[0].name.getText(sourceFile)).toBe('bot')
|
|
181
|
+
expect(fn!.parameters[1].name.getText(sourceFile)).toBe('deps')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('registers the EXACT golden ordered sequence inside the function', () => {
|
|
185
|
+
const fn = findFunction('registerGatewayHandlers')
|
|
186
|
+
expect(fn?.body).toBeDefined()
|
|
187
|
+
const actual: string[] = []
|
|
188
|
+
for (const stmt of fn!.body!.statements) {
|
|
189
|
+
const sig = registrationSig(stmt)
|
|
190
|
+
if (sig !== null) actual.push(sig)
|
|
191
|
+
}
|
|
192
|
+
// Deep, ordered equality — fails on drop, reorder, duplicate, or add.
|
|
193
|
+
expect(actual).toEqual([...GOLDEN_REGISTRATIONS])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('has no duplicate registrations', () => {
|
|
197
|
+
const fn = findFunction('registerGatewayHandlers')
|
|
198
|
+
const sigs: string[] = []
|
|
199
|
+
for (const stmt of fn!.body!.statements) {
|
|
200
|
+
const sig = registrationSig(stmt)
|
|
201
|
+
if (sig !== null) sigs.push(sig)
|
|
202
|
+
}
|
|
203
|
+
expect(new Set(sigs).size).toBe(sigs.length)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('leaves ZERO bot.* registrations at module top level (all live in the function)', () => {
|
|
207
|
+
const leaked: string[] = []
|
|
208
|
+
for (const stmt of sourceFile.statements) {
|
|
209
|
+
const sig = registrationSig(stmt)
|
|
210
|
+
if (sig !== null) leaked.push(sig)
|
|
211
|
+
}
|
|
212
|
+
expect(leaked).toEqual([])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('calls registerGatewayHandlers(bot, ...) exactly once, inside initGatewayBot (P0b), never at module top level', () => {
|
|
216
|
+
// P0b (#2996): the call moved off module top level into the boot function
|
|
217
|
+
// initGatewayBot(). Assert (a) ZERO top-level calls — proving the deferral —
|
|
218
|
+
// and (b) exactly one call in the whole file, with `bot` as its first arg,
|
|
219
|
+
// located inside initGatewayBot's body.
|
|
220
|
+
let topLevelCalls = 0
|
|
221
|
+
for (const stmt of sourceFile.statements) {
|
|
222
|
+
if (
|
|
223
|
+
ts.isExpressionStatement(stmt) &&
|
|
224
|
+
ts.isCallExpression(stmt.expression) &&
|
|
225
|
+
ts.isIdentifier(stmt.expression.expression) &&
|
|
226
|
+
stmt.expression.expression.text === 'registerGatewayHandlers'
|
|
227
|
+
) {
|
|
228
|
+
topLevelCalls++
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
expect(topLevelCalls).toBe(0)
|
|
232
|
+
|
|
233
|
+
// Count every registerGatewayHandlers(...) call anywhere in the module, and
|
|
234
|
+
// record whether it is lexically inside the initGatewayBot function.
|
|
235
|
+
const initGatewayBot = findFunction('initGatewayBot')
|
|
236
|
+
expect(initGatewayBot).toBeDefined()
|
|
237
|
+
const initStart = initGatewayBot!.getStart(sourceFile)
|
|
238
|
+
const initEnd = initGatewayBot!.getEnd()
|
|
239
|
+
|
|
240
|
+
let totalCalls = 0
|
|
241
|
+
let callsInsideInit = 0
|
|
242
|
+
const visit = (node: ts.Node): void => {
|
|
243
|
+
if (
|
|
244
|
+
ts.isCallExpression(node) &&
|
|
245
|
+
ts.isIdentifier(node.expression) &&
|
|
246
|
+
node.expression.text === 'registerGatewayHandlers'
|
|
247
|
+
) {
|
|
248
|
+
totalCalls++
|
|
249
|
+
// first arg is the bot singleton
|
|
250
|
+
expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
|
|
251
|
+
const start = node.getStart(sourceFile)
|
|
252
|
+
if (start >= initStart && node.getEnd() <= initEnd) callsInsideInit++
|
|
253
|
+
}
|
|
254
|
+
ts.forEachChild(node, visit)
|
|
255
|
+
}
|
|
256
|
+
visit(sourceFile)
|
|
257
|
+
|
|
258
|
+
expect(totalCalls).toBe(1)
|
|
259
|
+
expect(callsInsideInit).toBe(1)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('registers the admin-gate middleware (bot.use) FIRST and the error boundary (bot.catch) LAST', () => {
|
|
263
|
+
const fn = findFunction('registerGatewayHandlers')
|
|
264
|
+
const sigs: string[] = []
|
|
265
|
+
for (const stmt of fn!.body!.statements) {
|
|
266
|
+
const sig = registrationSig(stmt)
|
|
267
|
+
if (sig !== null) sigs.push(sig)
|
|
268
|
+
}
|
|
269
|
+
expect(sigs[0]).toBe('use')
|
|
270
|
+
expect(sigs[sigs.length - 1]).toBe('catch')
|
|
271
|
+
// the catch-all-ish specific media handlers precede message_reaction, which
|
|
272
|
+
// precedes the error boundary
|
|
273
|
+
expect(sigs.indexOf('on:message:text')).toBeGreaterThan(sigs.indexOf('use'))
|
|
274
|
+
expect(sigs.indexOf('on:message_reaction')).toBeGreaterThan(sigs.indexOf('on:message:text'))
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('installs the terminal catch-all AFTER every message:* handler and before message_reaction', () => {
|
|
278
|
+
const fn = findFunction('registerGatewayHandlers')
|
|
279
|
+
const sigs: string[] = []
|
|
280
|
+
for (const stmt of fn!.body!.statements) {
|
|
281
|
+
const sig = registrationSig(stmt)
|
|
282
|
+
if (sig !== null) sigs.push(sig)
|
|
283
|
+
}
|
|
284
|
+
const catchAllIdx = sigs.indexOf('helper:installUnhandledMessageCatchAll')
|
|
285
|
+
expect(catchAllIdx).toBeGreaterThan(0)
|
|
286
|
+
// Every message:* handler precedes the catch-all (grammY no-double-handling).
|
|
287
|
+
const messageHandlers = sigs.filter(s => /^on:message(:|$)/.test(s) && s !== 'on:message_reaction')
|
|
288
|
+
expect(messageHandlers.length).toBeGreaterThan(0)
|
|
289
|
+
for (const mh of messageHandlers) {
|
|
290
|
+
expect(sigs.indexOf(mh)).toBeLessThan(catchAllIdx)
|
|
291
|
+
}
|
|
292
|
+
// message_reaction (a different update type) is registered AFTER the catch-all.
|
|
293
|
+
expect(sigs.indexOf('on:message_reaction')).toBeGreaterThan(catchAllIdx)
|
|
294
|
+
// ops-info commands register between the last /command and callback_query:data.
|
|
295
|
+
const opsIdx = sigs.indexOf('helper:registerOpsInfoCommands')
|
|
296
|
+
expect(opsIdx).toBeGreaterThan(sigs.indexOf('command:usage'))
|
|
297
|
+
expect(opsIdx).toBeLessThan(sigs.indexOf('on:callback_query:data'))
|
|
298
|
+
})
|
|
299
|
+
})
|
|
@@ -3,42 +3,50 @@ import { readFileSync } from 'node:fs'
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Structural integration test for the loopback-paste fail-safe redaction in
|
|
6
|
-
* the
|
|
6
|
+
* the inbound intercept gauntlet (security audit #3084, F2/F3).
|
|
7
7
|
*
|
|
8
|
-
* Why structural:
|
|
9
|
-
* pure-functional invocation would require end-to-end harnessing of
|
|
10
|
-
* Bot/Grammy/Context. The decision predicate (shouldConsumeLoopbackPaste, incl.
|
|
8
|
+
* Why structural: the decision predicate (shouldConsumeLoopbackPaste, incl.
|
|
11
9
|
* the malformed-paste fail-safe) is exhaustively unit-tested in
|
|
12
10
|
* `auth-loopback-relay.test.ts`, and the redaction primitive
|
|
13
11
|
* (redactAuthCodeMessage) in `auth-code-redact.test.ts`. What's left to assert
|
|
14
|
-
* here is that the
|
|
15
|
-
* that is malformed, or arrives just after the intercept TTL, or has no
|
|
16
|
-
* flow, is redacted + dropped rather than forwarded to the agent.
|
|
12
|
+
* here is that the intercept path actually WIRES a fail-safe so a code-bearing
|
|
13
|
+
* paste that is malformed, or arrives just after the intercept TTL, or has no
|
|
14
|
+
* active flow, is redacted + dropped rather than forwarded to the agent.
|
|
17
15
|
*
|
|
18
|
-
*
|
|
16
|
+
* P7 PR-6 (#2996): the intercept body (pending-flow branch + fail-safe tail)
|
|
17
|
+
* moved verbatim into gateway/inbound-interceptors.ts
|
|
18
|
+
* (`interceptLoopbackRelay`); gateway.ts keeps the ordered delegation. The
|
|
19
|
+
* invariants are unchanged — only the file that owns each literal moved:
|
|
19
20
|
* 1. The stale (TTL-expired) loopback branch does NOT let the paste fall
|
|
20
|
-
* through to the agent — it flows into the fail-safe below.
|
|
21
|
-
* 2. A fail-safe `if (shouldConsumeLoopbackPaste(text))` block exists that
|
|
22
|
-
* redacts (redactAuthCodeMessage) and returns
|
|
23
|
-
* pending-flow branch
|
|
24
|
-
*
|
|
21
|
+
* through to the agent — it flows into the fail-safe below it.
|
|
22
|
+
* 2. A fail-safe `if (shouldConsumeLoopbackPaste(p.text))` block exists that
|
|
23
|
+
* redacts (deps.redactAuthCode → redactAuthCodeMessage) and returns
|
|
24
|
+
* handled, sitting AFTER the pending-flow branch; the gateway invokes the
|
|
25
|
+
* loopback intercept BEFORE the generic auth-code / reauth intercept.
|
|
25
26
|
*/
|
|
26
27
|
describe('gateway loopback-paste fail-safe redaction — structural wiring', () => {
|
|
27
|
-
const
|
|
28
|
+
const gatewaySrc = readFileSync(
|
|
28
29
|
new URL('../gateway/gateway.ts', import.meta.url),
|
|
29
30
|
'utf8',
|
|
30
31
|
)
|
|
32
|
+
const interceptorsSrc = readFileSync(
|
|
33
|
+
new URL('../gateway/inbound-interceptors.ts', import.meta.url),
|
|
34
|
+
'utf8',
|
|
35
|
+
)
|
|
31
36
|
|
|
32
|
-
it('
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
it('wires the consume predicate + redaction helper', () => {
|
|
38
|
+
// The intercept module owns the predicate; the gateway binds the
|
|
39
|
+
// redaction closure (deps.redactAuthCode → redactAuthCodeMessage).
|
|
40
|
+
expect(interceptorsSrc).toMatch(/shouldConsumeLoopbackPaste/)
|
|
41
|
+
expect(gatewaySrc).toMatch(/redactAuthCodeMessage/)
|
|
42
|
+
expect(gatewaySrc).toMatch(/interceptLoopbackRelay\(/)
|
|
35
43
|
})
|
|
36
44
|
|
|
37
45
|
it('has a fail-safe redact-and-return block guarded by shouldConsumeLoopbackPaste', () => {
|
|
38
|
-
// Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(text)) { ... }`
|
|
46
|
+
// Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(p.text)) { ... }`
|
|
39
47
|
// that is NOT part of the `pendingLoop && ...` pending-flow branch.
|
|
40
|
-
const failSafe =
|
|
41
|
-
/if \(shouldConsumeLoopbackPaste\(text\)\) \{[\s\S]{0,
|
|
48
|
+
const failSafe = interceptorsSrc.match(
|
|
49
|
+
/if \(shouldConsumeLoopbackPaste\(p\.text\)\) \{[\s\S]{0,700}?\n {2}\}/g,
|
|
42
50
|
)
|
|
43
51
|
expect(failSafe).not.toBeNull()
|
|
44
52
|
// The standalone fail-safe (the one NOT preceded by `pendingLoop &&`).
|
|
@@ -46,21 +54,28 @@ describe('gateway loopback-paste fail-safe redaction — structural wiring', ()
|
|
|
46
54
|
(b) => !b.startsWith('if (pendingLoop'),
|
|
47
55
|
)
|
|
48
56
|
expect(standalone).toBeDefined()
|
|
49
|
-
expect(standalone!).toMatch(/
|
|
50
|
-
expect(standalone!).toMatch(/return/)
|
|
57
|
+
expect(standalone!).toMatch(/deps\.redactAuthCode/)
|
|
58
|
+
expect(standalone!).toMatch(/return \{ handled: true \}/)
|
|
51
59
|
})
|
|
52
60
|
|
|
53
61
|
it('the stale loopback branch falls through to the fail-safe (does not forward)', () => {
|
|
54
|
-
// The
|
|
55
|
-
//
|
|
56
|
-
// auth-code intercept (`pendingReauthFlows`).
|
|
57
|
-
const failSafeIdx =
|
|
62
|
+
// The fail-safe block must appear AFTER the pending-loop branch inside the
|
|
63
|
+
// intercept module; and the gateway must invoke the loopback intercept
|
|
64
|
+
// BEFORE the generic auth-code intercept (`pendingReauthFlows`).
|
|
65
|
+
const failSafeIdx = interceptorsSrc.indexOf(
|
|
58
66
|
'Fail-safe redaction (security audit #3084, F2/F3)',
|
|
59
67
|
)
|
|
60
|
-
const pendingLoopIdx =
|
|
61
|
-
|
|
68
|
+
const pendingLoopIdx = interceptorsSrc.indexOf(
|
|
69
|
+
'const pendingLoop = pendingLoopbackFlows.get',
|
|
70
|
+
)
|
|
62
71
|
expect(failSafeIdx).toBeGreaterThan(-1)
|
|
63
72
|
expect(failSafeIdx).toBeGreaterThan(pendingLoopIdx)
|
|
64
|
-
|
|
73
|
+
|
|
74
|
+
const loopbackCallIdx = gatewaySrc.indexOf('interceptLoopbackRelay(')
|
|
75
|
+
// P7 PR-7: the reauth intercept body also moved; its gateway call site is
|
|
76
|
+
// the ordering anchor.
|
|
77
|
+
const reauthIdx = gatewaySrc.indexOf('interceptReauth(')
|
|
78
|
+
expect(loopbackCallIdx).toBeGreaterThan(-1)
|
|
79
|
+
expect(reauthIdx).toBeGreaterThan(loopbackCallIdx)
|
|
65
80
|
})
|
|
66
81
|
})
|
|
@@ -22,6 +22,19 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
22
22
|
new URL('../gateway/gateway.ts', import.meta.url),
|
|
23
23
|
'utf8',
|
|
24
24
|
)
|
|
25
|
+
// #2996 P2: executeReply's body moved VERBATIM to outbound-send-path.ts
|
|
26
|
+
// (`sendReply`); the reply-path scrub-ordering assertion reads there. The
|
|
27
|
+
// injected redactor (`redactOutboundText`) still lives in gateway.ts.
|
|
28
|
+
const sendPathSrc = readFileSync(
|
|
29
|
+
new URL('../gateway/outbound-send-path.ts', import.meta.url),
|
|
30
|
+
'utf8',
|
|
31
|
+
)
|
|
32
|
+
// #2996 P4-A: the turn-flush backstop scrub lives in handleSessionEvent,
|
|
33
|
+
// which moved VERBATIM to stream-render.ts.
|
|
34
|
+
const streamSrc = readFileSync(
|
|
35
|
+
new URL('../gateway/stream-render.ts', import.meta.url),
|
|
36
|
+
'utf8',
|
|
37
|
+
)
|
|
25
38
|
|
|
26
39
|
it('imports the shared redactor', () => {
|
|
27
40
|
expect(src).toMatch(/import \{ redact \} from '\.\.\/secret-detect\/redact\.js'/)
|
|
@@ -39,9 +52,9 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
39
52
|
// voice-scrub) is extracted into outbound-send-path.ts. The reply path now
|
|
40
53
|
// delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
|
|
41
54
|
// — the injected redactor still runs at entry, before the stderr preview.
|
|
42
|
-
const start =
|
|
43
|
-
const redactIdx =
|
|
44
|
-
const previewIdx =
|
|
55
|
+
const start = sendPathSrc.indexOf('export async function sendReply(')
|
|
56
|
+
const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
|
|
57
|
+
const previewIdx = sendPathSrc.indexOf('reply: invoked chatId=', start)
|
|
45
58
|
expect(start).toBeGreaterThan(0)
|
|
46
59
|
expect(redactIdx).toBeGreaterThan(start)
|
|
47
60
|
expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged
|
|
@@ -59,8 +72,8 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
59
72
|
it('turn-flush backstop: scrubs the model terminal prose before send', () => {
|
|
60
73
|
// Turn-flush delivers the model's answer when it skipped reply/stream_reply
|
|
61
74
|
// — arbitrary agent free-text that hits the wire + stderr preview.
|
|
62
|
-
const redactIdx =
|
|
63
|
-
const scrubSiteIdx =
|
|
75
|
+
const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
|
|
76
|
+
const scrubSiteIdx = streamSrc.indexOf(`site: 'turn_flush'`)
|
|
64
77
|
expect(redactIdx).toBeGreaterThan(0)
|
|
65
78
|
expect(scrubSiteIdx).toBeGreaterThan(redactIdx) // mask BEFORE the voice scrub + send
|
|
66
79
|
})
|
|
@@ -15,6 +15,10 @@ import { readFileSync } from 'node:fs'
|
|
|
15
15
|
*/
|
|
16
16
|
describe('request_secret — gateway wiring', () => {
|
|
17
17
|
const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
18
|
+
// executeRequestSecret moved verbatim to card-tool-handlers.ts (#2996 P5-tail);
|
|
19
|
+
// the handler-body pins below read that module. The dispatch case + the
|
|
20
|
+
// secure-capture path (captureProvidedSecret / handleInbound) stay in gateway.ts.
|
|
21
|
+
const cardTool = readFileSync(new URL('../gateway/card-tool-handlers.ts', import.meta.url), 'utf8')
|
|
18
22
|
const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
|
|
19
23
|
|
|
20
24
|
it('declares the MCP tool with required {chat_id,key} and NO value arg', () => {
|
|
@@ -30,7 +34,7 @@ describe('request_secret — gateway wiring', () => {
|
|
|
30
34
|
|
|
31
35
|
it('is allow-listed and dispatched', () => {
|
|
32
36
|
expect(gw).toMatch(/'request_secret',\n/)
|
|
33
|
-
expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
|
|
37
|
+
expect(gw).toMatch(/case 'request_secret':\s*\n\s*return cardToolHandlers\.executeRequestSecret\(args\)/)
|
|
34
38
|
})
|
|
35
39
|
|
|
36
40
|
it('routes the vsp: callback', () => {
|
|
@@ -71,8 +75,8 @@ describe('request_secret — gateway wiring', () => {
|
|
|
71
75
|
})
|
|
72
76
|
|
|
73
77
|
it('dedupes to one open request per (chat,key)', () => {
|
|
74
|
-
const idx =
|
|
75
|
-
const body =
|
|
78
|
+
const idx = cardTool.indexOf('async function executeRequestSecret(')
|
|
79
|
+
const body = cardTool.slice(idx, idx + 1800)
|
|
76
80
|
expect(body).toMatch(/p\.chat_id === chat_id && p\.key === key/)
|
|
77
81
|
})
|
|
78
82
|
})
|