switchroom 0.18.8 → 0.18.9

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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -0,0 +1,701 @@
1
+ /**
2
+ * Harness for the callback-query handler extraction (#2996 Phase 5,
3
+ * remaining item 2).
4
+ *
5
+ * `createCallbackQueryHandlers` receives every gateway dep injected, so the
6
+ * handler families run here against a fake bot / fake deps with the REAL
7
+ * store factories (`createSweepableCardStore` / `createSweepableStore`) —
8
+ * the same surfaces gateway.ts wires in. Covered routes:
9
+ *
10
+ * - vrd:* authorization + payload validation (vault recent-denials)
11
+ * - vra:* approve/deny lifecycle: deny wake-up inbound, expired card,
12
+ * admin-only non-admin refusal, passphrase-capture queueing
13
+ * - vrs:* discard wake-up, rename intercept (secret capture)
14
+ * - vd:* deferred-secret cancel (kernel deny recorded) + unlock intercept
15
+ * - vg:* grant-wizard step state machine (cancel / expired session)
16
+ * + pure helpers (parseGrantDuration, formatGrantExpiry, keyboards)
17
+ * - mmp:* deny resolution + expired-card TTL enforcement at tap time
18
+ * - sp:* authorization gate + malformed payload
19
+ * - op:* dismiss finalize, restart happy-path, agent-name validation
20
+ * - auth:* refresh throttle + unknown-button dismissal (auth dashboard)
21
+ *
22
+ * Broker-touching paths (mint/list over the real UDS client) are exercised
23
+ * only up to the seam where the handler would leave process state — the
24
+ * assertions here pin the state transitions and card edits that precede any
25
+ * broker call, which is exactly the behavior the extraction must not change.
26
+ */
27
+
28
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
29
+ import type { Context } from 'grammy'
30
+ import {
31
+ createCallbackQueryHandlers,
32
+ type CallbackQueryHandlersDeps,
33
+ type PendingVaultRequestAccess,
34
+ type PendingVaultRequestSave,
35
+ type PendingMentalModelPropose,
36
+ type DeferredSecret,
37
+ type PendingVaultOp,
38
+ } from '../gateway/callback-query-handlers.js'
39
+ import { createSweepableCardStore } from '../gateway/approval-card-stores.js'
40
+ import { createSweepableStore } from '../gateway/pending-state-stores.js'
41
+ import { StagingMap } from '../secret-detect/staging.js'
42
+ import { InlineKeyboard } from 'grammy'
43
+
44
+ // ── Fakes ────────────────────────────────────────────────────────────────
45
+
46
+ interface FakeCtxOpts {
47
+ data?: string
48
+ senderId?: string
49
+ chatId?: string
50
+ messageId?: number
51
+ messageText?: string
52
+ threadId?: number
53
+ username?: string
54
+ }
55
+
56
+ function makeCtx(opts: FakeCtxOpts = {}) {
57
+ const {
58
+ data = '',
59
+ senderId = '111',
60
+ chatId = '111',
61
+ messageId = 42,
62
+ messageText = 'card body',
63
+ threadId,
64
+ username = 'op',
65
+ } = opts
66
+ const calls: Record<string, unknown[][]> = {
67
+ answerCallbackQuery: [],
68
+ editMessageText: [],
69
+ editMessageReplyMarkup: [],
70
+ apiEditMessageText: [],
71
+ replyWithRichMessage: [],
72
+ reply: [],
73
+ }
74
+ const ctx = {
75
+ from: { id: Number(senderId), username, first_name: username },
76
+ chat: { id: Number(chatId) },
77
+ callbackQuery: {
78
+ data,
79
+ message: {
80
+ message_id: messageId,
81
+ chat: { id: Number(chatId) },
82
+ text: messageText,
83
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
84
+ },
85
+ },
86
+ answerCallbackQuery: vi.fn(async (...a: unknown[]) => {
87
+ calls.answerCallbackQuery.push(a)
88
+ return true
89
+ }),
90
+ editMessageText: vi.fn(async (...a: unknown[]) => {
91
+ calls.editMessageText.push(a)
92
+ return true
93
+ }),
94
+ editMessageReplyMarkup: vi.fn(async (...a: unknown[]) => {
95
+ calls.editMessageReplyMarkup.push(a)
96
+ return true
97
+ }),
98
+ replyWithRichMessage: vi.fn(async (...a: unknown[]) => {
99
+ calls.replyWithRichMessage.push(a)
100
+ return { message_id: 900 }
101
+ }),
102
+ api: {
103
+ editMessageText: vi.fn(async (...a: unknown[]) => {
104
+ calls.apiEditMessageText.push(a)
105
+ return true
106
+ }),
107
+ sendRichMessage: vi.fn(async () => ({ message_id: 901 })),
108
+ },
109
+ }
110
+ return { ctx: ctx as unknown as Context, raw: ctx, calls }
111
+ }
112
+
113
+ function makeDeps(overrides: Partial<CallbackQueryHandlersDeps> = {}) {
114
+ const injected: Array<{ agent: string; text: string }> = []
115
+ const fakeBot = {
116
+ api: {
117
+ editMessageText: vi.fn(async () => true),
118
+ sendRichMessage: vi.fn(async () => ({ message_id: 902 })),
119
+ },
120
+ }
121
+ const deps: CallbackQueryHandlersDeps = {
122
+ bot: fakeBot,
123
+ lockedBot: fakeBot,
124
+ loadAccess: () => ({ allowFrom: ['111', '222'] }),
125
+ escapeHtmlForTg: (t) =>
126
+ t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
127
+ switchroomReply: vi.fn(async () => undefined),
128
+ resolveThreadId: (_chatId, explicit) =>
129
+ explicit == null ? undefined : Number(explicit),
130
+ deliverResumeSyntheticOrBuffer: vi.fn((agent: string, inbound: { text: string }) => {
131
+ injected.push({ agent, text: inbound.text })
132
+ return true
133
+ }) as unknown as CallbackQueryHandlersDeps['deliverResumeSyntheticOrBuffer'],
134
+ expireMentalModelProposeCard: vi.fn(),
135
+ readLiveSwitchroomConfigText: () => 'agents: {}\n',
136
+ mentalModelCorrelationKey: (a, d) => `${a}::${d.length}`,
137
+ getMyAgentName: () => 'test-agent',
138
+ triggerSelfRestart: vi.fn(() => true),
139
+ runSwitchroomAuthCommand: vi.fn(async () => undefined),
140
+ switchroomExecJson: <T,>(_args: string[]): T | null => null,
141
+ assertSafeAgentName: (name: string) => {
142
+ if (!/^[a-z][a-z0-9-]{0,62}$/i.test(name)) throw new Error('unsafe agent name')
143
+ },
144
+ buildDeferredSecretKeyboard: () => new InlineKeyboard().text('x', 'vd:unlock:k'),
145
+ recordDeferredSecretKernelDecision: vi.fn(async () => undefined),
146
+ mintGrantWizardKernelRequest: vi.fn(async () => null),
147
+ recordGrantWizardKernelDecision: vi.fn(async () => undefined),
148
+ robustApiCall: (fn) => fn(),
149
+ swallowingApiCall: async (fn) => {
150
+ try {
151
+ return await fn()
152
+ } catch {
153
+ return undefined
154
+ }
155
+ },
156
+ pendingVaultRequestAccesses: createSweepableCardStore<PendingVaultRequestAccess>({
157
+ isExpired: () => false,
158
+ expire: () => () => {},
159
+ log: () => () => {},
160
+ }),
161
+ pendingVaultRequestSaves: createSweepableCardStore<PendingVaultRequestSave>({
162
+ isExpired: () => false,
163
+ expire: () => () => {},
164
+ log: () => () => {},
165
+ }),
166
+ pendingMentalModelProposes: createSweepableCardStore<PendingMentalModelPropose>({
167
+ isExpired: () => false,
168
+ expire: () => () => {},
169
+ log: () => () => {},
170
+ }),
171
+ pendingCardStore: { remove: vi.fn() },
172
+ pendingMentalModelCorrelations: createSweepableStore(() => false),
173
+ pendingVaultOps: createSweepableStore<PendingVaultOp>(() => false),
174
+ vaultPassphraseCache: createSweepableStore(() => false),
175
+ deferredSecrets: createSweepableStore<DeferredSecret>(() => false),
176
+ pendingReauthFlows: createSweepableStore(() => false),
177
+ secretStaging: new StagingMap(),
178
+ lastAuthRefreshAtMs: new Map<string, number>(),
179
+ getVaultApprovalAuthMode: () => 'passphrase',
180
+ getAdminOnlyKeys: () => [],
181
+ vaultKeyRegex: /^[A-Za-z0-9_./-]{1,200}$/,
182
+ mentalModelProposeTtlMs: 60 * 60 * 1000,
183
+ ...overrides,
184
+ }
185
+ return { deps, injected, fakeBot }
186
+ }
187
+
188
+ function stagedAccess(over: Partial<PendingVaultRequestAccess> = {}): PendingVaultRequestAccess {
189
+ return {
190
+ agent: 'worker',
191
+ chat_id: '111',
192
+ card_message_id: 42,
193
+ key: 'github/token',
194
+ scope: 'read',
195
+ ttl_seconds: 30 * 24 * 60 * 60,
196
+ staged_at: Date.now(),
197
+ ...over,
198
+ }
199
+ }
200
+
201
+ // ── vrd:* — recent-denials one-tap allow ────────────────────────────────
202
+
203
+ describe('handleVaultRecentDenialCallback', () => {
204
+ it('refuses a sender not on allowFrom', async () => {
205
+ const { deps } = makeDeps()
206
+ const h = createCallbackQueryHandlers(deps)
207
+ const { ctx, raw } = makeCtx({ senderId: '999', data: 'vrd:worker:github/token' })
208
+ await h.handleVaultRecentDenialCallback(ctx, 'vrd:worker:github/token')
209
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
210
+ })
211
+
212
+ it('rejects a malformed payload before any broker work', async () => {
213
+ const { deps } = makeDeps()
214
+ const h = createCallbackQueryHandlers(deps)
215
+ const { ctx, raw } = makeCtx()
216
+ await h.handleVaultRecentDenialCallback(ctx, 'vrd:only-two-parts')
217
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Bad request' })
218
+ })
219
+
220
+ it('rejects an invalid agent slug', async () => {
221
+ const { deps } = makeDeps()
222
+ const h = createCallbackQueryHandlers(deps)
223
+ const { ctx, raw } = makeCtx()
224
+ await h.handleVaultRecentDenialCallback(ctx, 'vrd:__bad:key')
225
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Invalid agent name' })
226
+ })
227
+ })
228
+
229
+ // ── vra:* — vault_request_access approve/deny ───────────────────────────
230
+
231
+ describe('handleVaultRequestAccessCallback', () => {
232
+ it('edits an expired/unknown stage card and stops', async () => {
233
+ const { deps } = makeDeps()
234
+ const h = createCallbackQueryHandlers(deps)
235
+ const { ctx, raw } = makeCtx()
236
+ await h.handleVaultRequestAccessCallback(ctx, 'vra:approve:nope')
237
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
238
+ text: 'Card expired — ask the agent to re-request.',
239
+ })
240
+ expect(raw.api.editMessageText).toHaveBeenCalled()
241
+ })
242
+
243
+ it('deny: drops the stage, edits the card, wakes the agent with a denied inbound', async () => {
244
+ const { deps, injected } = makeDeps()
245
+ deps.pendingVaultRequestAccesses.set('s1', stagedAccess())
246
+ const h = createCallbackQueryHandlers(deps)
247
+ const { ctx, raw } = makeCtx()
248
+ await h.handleVaultRequestAccessCallback(ctx, 'vra:deny:s1')
249
+ expect(deps.pendingVaultRequestAccesses.has('s1')).toBe(false)
250
+ expect(deps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
251
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: '🚫 Denied' })
252
+ expect(raw.api.editMessageText).toHaveBeenCalled()
253
+ expect(injected).toHaveLength(1)
254
+ expect(injected[0]!.agent).toBe('worker')
255
+ expect(injected[0]!.text).toContain('github/token')
256
+ })
257
+
258
+ it('approve on an admin-only key from a non-admin allowFrom member is refused (card intact)', async () => {
259
+ const { deps } = makeDeps({ getAdminOnlyKeys: () => ['github/token'] })
260
+ deps.pendingVaultRequestAccesses.set('s1', stagedAccess())
261
+ const h = createCallbackQueryHandlers(deps)
262
+ // 222 is on allowFrom but is not allowFrom[0].
263
+ const { ctx, raw } = makeCtx({ senderId: '222' })
264
+ await h.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')
265
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
266
+ text: '🔒 Admin-only credential — only the owner can approve this.',
267
+ })
268
+ // Stage must survive so the admin can still approve.
269
+ expect(deps.pendingVaultRequestAccesses.has('s1')).toBe(true)
270
+ })
271
+
272
+ it('approve without a cached passphrase queues a passphrase-for-access-approve intercept', async () => {
273
+ const { deps, fakeBot } = makeDeps()
274
+ deps.pendingVaultRequestAccesses.set('s1', stagedAccess())
275
+ const h = createCallbackQueryHandlers(deps)
276
+ const { ctx } = makeCtx()
277
+ await h.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')
278
+ const op = deps.pendingVaultOps.get('111')
279
+ expect(op?.kind).toBe('passphrase-for-access-approve')
280
+ if (op?.kind === 'passphrase-for-access-approve') {
281
+ expect(op.items).toEqual([
282
+ { stageId: 's1', cardChatId: '111', cardMessageId: 42, senderId: '111' },
283
+ ])
284
+ }
285
+ // A fresh passphrase prompt goes out via the locked bot.
286
+ expect(fakeBot.api.sendRichMessage).toHaveBeenCalled()
287
+ })
288
+
289
+ it('a second approve tap joins the existing passphrase queue instead of overwriting it', async () => {
290
+ const { deps } = makeDeps()
291
+ deps.pendingVaultRequestAccesses.set('s1', stagedAccess())
292
+ deps.pendingVaultRequestAccesses.set('s2', stagedAccess({ key: 'coolify/api-token', card_message_id: 43 }))
293
+ const h = createCallbackQueryHandlers(deps)
294
+ await h.handleVaultRequestAccessCallback(makeCtx().ctx, 'vra:approve:s1')
295
+ await h.handleVaultRequestAccessCallback(makeCtx({ messageId: 43 }).ctx, 'vra:approve:s2')
296
+ const op = deps.pendingVaultOps.get('111')
297
+ expect(op?.kind).toBe('passphrase-for-access-approve')
298
+ if (op?.kind === 'passphrase-for-access-approve') {
299
+ expect(op.items.map((i) => i.stageId)).toEqual(['s1', 's2'])
300
+ }
301
+ })
302
+ })
303
+
304
+ // ── vrs:* — vault_request_save (secret capture) ─────────────────────────
305
+
306
+ function stagedSave(over: Partial<PendingVaultRequestSave> = {}): PendingVaultRequestSave {
307
+ return {
308
+ agent: 'worker',
309
+ chat_id: '111',
310
+ card_message_id: 42,
311
+ key: 'github/token',
312
+ kind: 'string',
313
+ value: 'sk-' + 'fake-value',
314
+ staged_at: Date.now(),
315
+ ...over,
316
+ }
317
+ }
318
+
319
+ describe('handleVaultRequestSaveCallback', () => {
320
+ it('discard: drops the stage, edits the card, wakes the agent', async () => {
321
+ const { deps, injected } = makeDeps()
322
+ deps.pendingVaultRequestSaves.set('s1', stagedSave())
323
+ const h = createCallbackQueryHandlers(deps)
324
+ const { ctx, raw } = makeCtx()
325
+ await h.handleVaultRequestSaveCallback(ctx, 'vrs:discard:s1')
326
+ expect(deps.pendingVaultRequestSaves.has('s1')).toBe(false)
327
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: '🚫 Discarded' })
328
+ expect(injected).toHaveLength(1)
329
+ expect(injected[0]!.text).toContain('github/token')
330
+ })
331
+
332
+ it('rename: registers the rename-vault-save intercept and finalizes the card', async () => {
333
+ const { deps } = makeDeps()
334
+ deps.pendingVaultRequestSaves.set('s1', stagedSave())
335
+ const h = createCallbackQueryHandlers(deps)
336
+ const { ctx, raw } = makeCtx()
337
+ await h.handleVaultRequestSaveCallback(ctx, 'vrs:rename:s1')
338
+ const op = deps.pendingVaultOps.get('111')
339
+ expect(op?.kind).toBe('rename-vault-save')
340
+ if (op?.kind === 'rename-vault-save') expect(op.stageId).toBe('s1')
341
+ // finalizeCallback path: ack toast + atomic edit.
342
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
343
+ text: 'Send the new key name as your next message.',
344
+ })
345
+ expect(raw.editMessageText).toHaveBeenCalled()
346
+ })
347
+
348
+ it('save on a restart-restored card (value lost) degrades gracefully and wakes the agent', async () => {
349
+ const { deps, injected } = makeDeps()
350
+ deps.pendingVaultRequestSaves.set('s1', stagedSave({ restoredWithoutValue: true, value: '' }))
351
+ const h = createCallbackQueryHandlers(deps)
352
+ const { ctx, raw } = makeCtx()
353
+ await h.handleVaultRequestSaveCallback(ctx, 'vrs:save:s1')
354
+ expect(deps.pendingVaultRequestSaves.has('s1')).toBe(false)
355
+ expect(raw.api.editMessageText).toHaveBeenCalled()
356
+ expect(injected).toHaveLength(1)
357
+ expect(injected[0]!.text.toLowerCase()).toContain('github/token')
358
+ })
359
+ })
360
+
361
+ // ── vd:* — deferred-secret capture ──────────────────────────────────────
362
+
363
+ function deferred(over: Partial<DeferredSecret> = {}): DeferredSecret {
364
+ return {
365
+ chat_id: '111',
366
+ original_message_id: 7,
367
+ text: 'hunter2-secret',
368
+ staged_at: Date.now(),
369
+ suggested_slug: 'my_secret',
370
+ kernel_request_id: 'kr_1',
371
+ ...over,
372
+ }
373
+ }
374
+
375
+ describe('handleVaultDeferCallback', () => {
376
+ it('cancel: records the kernel deny, drops the secret, strips the card', async () => {
377
+ const { deps } = makeDeps()
378
+ deps.deferredSecrets.set('111:7', deferred())
379
+ const h = createCallbackQueryHandlers(deps)
380
+ const { ctx, raw } = makeCtx()
381
+ await h.handleVaultDeferCallback(ctx, 'vd:cancel:111:7')
382
+ expect(deps.recordDeferredSecretKernelDecision).toHaveBeenCalledWith(
383
+ 'kr_1',
384
+ 'deny',
385
+ 111,
386
+ ['111', '222'],
387
+ )
388
+ expect(deps.deferredSecrets.has('111:7')).toBe(false)
389
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Discarded.' })
390
+ })
391
+
392
+ it('unlock without a cached passphrase registers the passphrase-for-deferred intercept', async () => {
393
+ const { deps } = makeDeps()
394
+ deps.deferredSecrets.set('111:7', deferred())
395
+ const h = createCallbackQueryHandlers(deps)
396
+ const { ctx, raw } = makeCtx()
397
+ await h.handleVaultDeferCallback(ctx, 'vd:unlock:111:7')
398
+ expect(deps.recordDeferredSecretKernelDecision).toHaveBeenCalledWith(
399
+ 'kr_1',
400
+ 'allow_once',
401
+ 111,
402
+ ['111', '222'],
403
+ )
404
+ const op = deps.pendingVaultOps.get('111')
405
+ expect(op?.kind).toBe('passphrase-for-deferred')
406
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Send your passphrase…' })
407
+ })
408
+
409
+ it('expired card: toast + keyboard strip, no state change', async () => {
410
+ const { deps } = makeDeps()
411
+ const h = createCallbackQueryHandlers(deps)
412
+ const { ctx, raw } = makeCtx()
413
+ await h.handleVaultDeferCallback(ctx, 'vd:unlock:111:7')
414
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
415
+ text: 'This card expired. Re-send the secret.',
416
+ })
417
+ expect(raw.editMessageReplyMarkup).toHaveBeenCalled()
418
+ })
419
+ })
420
+
421
+ // ── mmp:* — mental-model proposal ───────────────────────────────────────
422
+
423
+ function stagedPropose(over: Partial<PendingMentalModelPropose> = {}): PendingMentalModelPropose {
424
+ return {
425
+ agent: 'worker',
426
+ chat_id: '111',
427
+ card_message_id: 42,
428
+ spec: { name: 'user-goals', source_query: 'what does the user want' },
429
+ staged_at: Date.now(),
430
+ ...over,
431
+ }
432
+ }
433
+
434
+ describe('handleMentalModelProposeCallback', () => {
435
+ it('deny: resolves the proposal, edits the card, wakes the agent', async () => {
436
+ const { deps, injected } = makeDeps()
437
+ deps.pendingMentalModelProposes.set('m1', stagedPropose())
438
+ const h = createCallbackQueryHandlers(deps)
439
+ const { ctx, raw } = makeCtx()
440
+ await h.handleMentalModelProposeCallback(ctx, 'mmp:deny:m1')
441
+ expect(deps.pendingMentalModelProposes.has('m1')).toBe(false)
442
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: '🚫 Denied' })
443
+ expect(raw.api.editMessageText).toHaveBeenCalled()
444
+ expect(injected.length).toBeGreaterThan(0)
445
+ })
446
+
447
+ it('a tap past the TTL routes through the shared expiry path instead of resolving', async () => {
448
+ const { deps } = makeDeps({ mentalModelProposeTtlMs: 1000 })
449
+ const stale = stagedPropose({ staged_at: Date.now() - 10_000 })
450
+ deps.pendingMentalModelProposes.set('m1', stale)
451
+ const h = createCallbackQueryHandlers(deps)
452
+ const { ctx, raw } = makeCtx()
453
+ await h.handleMentalModelProposeCallback(ctx, 'mmp:approve:m1')
454
+ expect(deps.expireMentalModelProposeCard).toHaveBeenCalledWith('m1', stale, expect.any(Number))
455
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
456
+ text: 'Card expired — the agent was notified.',
457
+ })
458
+ })
459
+
460
+ it('refuses a non-allowlisted tapper (agent self-approve is impossible)', async () => {
461
+ const { deps } = makeDeps()
462
+ deps.pendingMentalModelProposes.set('m1', stagedPropose())
463
+ const h = createCallbackQueryHandlers(deps)
464
+ const { ctx, raw } = makeCtx({ senderId: '999' })
465
+ await h.handleMentalModelProposeCallback(ctx, 'mmp:approve:m1')
466
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
467
+ expect(deps.pendingMentalModelProposes.has('m1')).toBe(true)
468
+ })
469
+ })
470
+
471
+ // ── sp:* — skill proposal ───────────────────────────────────────────────
472
+
473
+ describe('handleSkillProposalCallback', () => {
474
+ it('refuses a non-allowlisted tapper', async () => {
475
+ const { deps } = makeDeps()
476
+ const h = createCallbackQueryHandlers(deps)
477
+ const { ctx, raw } = makeCtx({ senderId: '999' })
478
+ await h.handleSkillProposalCallback(ctx, 'sp:approve:p1')
479
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
480
+ })
481
+
482
+ it('rejects a malformed payload', async () => {
483
+ const { deps } = makeDeps()
484
+ const h = createCallbackQueryHandlers(deps)
485
+ const { ctx, raw } = makeCtx()
486
+ await h.handleSkillProposalCallback(ctx, 'sp:bogus')
487
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Bad request' })
488
+ })
489
+ })
490
+
491
+ // ── op:* — operator-event card actions ──────────────────────────────────
492
+
493
+ describe('handleOperatorEventCallback', () => {
494
+ it('dismiss finalizes the card with a status line', async () => {
495
+ const { deps } = makeDeps()
496
+ const h = createCallbackQueryHandlers(deps)
497
+ const { ctx, raw } = makeCtx({ messageText: 'agent worker crashed' })
498
+ await h.handleOperatorEventCallback(ctx, 'op:dismiss:worker')
499
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Dismissed' })
500
+ const edit = raw.editMessageText.mock.calls[0]!
501
+ expect(JSON.stringify(edit[0])).toContain('Dismissed by operator')
502
+ })
503
+
504
+ it('restart triggers the restart and finalizes on success', async () => {
505
+ const { deps } = makeDeps()
506
+ const h = createCallbackQueryHandlers(deps)
507
+ const { ctx, raw } = makeCtx()
508
+ await h.handleOperatorEventCallback(ctx, 'op:restart:worker')
509
+ expect(deps.triggerSelfRestart).toHaveBeenCalledWith('worker', 'inline-button-restart')
510
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Restarting worker…' })
511
+ })
512
+
513
+ it('rejects an invalid agent name', async () => {
514
+ const { deps } = makeDeps()
515
+ const h = createCallbackQueryHandlers(deps)
516
+ const { ctx, raw } = makeCtx()
517
+ await h.handleOperatorEventCallback(ctx, `op:restart:${encodeURIComponent('../etc')}`)
518
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Invalid agent name.' })
519
+ expect(deps.triggerSelfRestart).not.toHaveBeenCalled()
520
+ })
521
+
522
+ it('reauth finalizes the card and seeds pendingReauthFlows via synthInbound', async () => {
523
+ const { deps } = makeDeps()
524
+ const h = createCallbackQueryHandlers(deps)
525
+ const { ctx } = makeCtx({ threadId: 5 })
526
+ await h.handleOperatorEventCallback(ctx, 'op:reauth:worker')
527
+ expect(deps.runSwitchroomAuthCommand).toHaveBeenCalledWith(
528
+ expect.anything(),
529
+ ['auth', 'reauth', 'worker'],
530
+ 'auth reauth worker',
531
+ )
532
+ const flow = deps.pendingReauthFlows.get('111:5')
533
+ expect(flow?.agent).toBe('worker')
534
+ })
535
+ })
536
+
537
+ // ── auth:* — auth dashboard ─────────────────────────────────────────────
538
+
539
+ describe('handleAuthDashboardCallback', () => {
540
+ it('unknown auth:* buttons are dismissed with a /auth hint', async () => {
541
+ const { deps } = makeDeps()
542
+ const h = createCallbackQueryHandlers(deps)
543
+ const { ctx, raw } = makeCtx({ data: 'auth:whatever' })
544
+ await h.handleAuthDashboardCallback(ctx)
545
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
546
+ text: 'Unknown auth button. Send /auth for current state.',
547
+ show_alert: false,
548
+ })
549
+ })
550
+
551
+ it('auth:refresh inside the throttle window toasts instead of re-probing', async () => {
552
+ const { deps } = makeDeps()
553
+ const h = createCallbackQueryHandlers(deps)
554
+ deps.lastAuthRefreshAtMs.set('111:42', Date.now())
555
+ const { ctx, raw } = makeCtx({ data: 'auth:refresh' })
556
+ await h.handleAuthDashboardCallback(ctx)
557
+ const toast = raw.answerCallbackQuery.mock.calls[0]![0] as { text: string }
558
+ expect(toast.text).toMatch(/Just refreshed — try again in \d+s/)
559
+ })
560
+
561
+ it('auth:use with a missing label toasts and stops', async () => {
562
+ const { deps } = makeDeps()
563
+ const h = createCallbackQueryHandlers(deps)
564
+ const { ctx, raw } = makeCtx({ data: 'auth:use:' })
565
+ await h.handleAuthDashboardCallback(ctx)
566
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
567
+ text: 'Missing account label.',
568
+ show_alert: false,
569
+ })
570
+ })
571
+ })
572
+
573
+ // ── vg:* — grant wizard + management ────────────────────────────────────
574
+
575
+ describe('grant wizard', () => {
576
+ it('parseGrantDuration parses d/h and rejects junk', () => {
577
+ const { deps } = makeDeps()
578
+ const h = createCallbackQueryHandlers(deps)
579
+ expect(h.parseGrantDuration('30d')).toBe(30 * 86400)
580
+ expect(h.parseGrantDuration('12h')).toBe(12 * 3600)
581
+ expect(h.parseGrantDuration('0d')).toBeNull()
582
+ expect(h.parseGrantDuration('soon')).toBeNull()
583
+ })
584
+
585
+ it('formatGrantExpiry renders Never and an ISO date', () => {
586
+ const { deps } = makeDeps()
587
+ const h = createCallbackQueryHandlers(deps)
588
+ expect(h.formatGrantExpiry(null)).toBe('Never')
589
+ const now = new Date('2026-07-11T00:00:00Z')
590
+ expect(h.formatGrantExpiry(86400, now)).toBe('2026-07-12')
591
+ })
592
+
593
+ it('keyboards carry the vg:* callback data contract', () => {
594
+ const { deps } = makeDeps()
595
+ const h = createCallbackQueryHandlers(deps)
596
+ const kb = h.buildGrantKeysKeyboard(['a', 'b'], new Set(['b']))
597
+ const flat = kb.inline_keyboard.flat() as Array<{ text: string; callback_data?: string }>
598
+ expect(flat.map((b) => b.callback_data)).toEqual([
599
+ 'vg:key:a',
600
+ 'vg:key:b',
601
+ 'vg:keys-continue',
602
+ 'vg:cancel',
603
+ ])
604
+ expect(flat[0]!.text).toBe('☐ a')
605
+ expect(flat[1]!.text).toBe('☑ b')
606
+ })
607
+
608
+ it('vg:cancel clears the wizard state and records the kernel deny when confirm was reached', async () => {
609
+ const { deps } = makeDeps()
610
+ deps.pendingVaultOps.set('111', {
611
+ kind: 'grant-wizard',
612
+ step: 'confirm',
613
+ agent: 'worker',
614
+ selectedKeys: ['k'],
615
+ kernel_request_id: 'kr_9',
616
+ startedAt: Date.now(),
617
+ })
618
+ const h = createCallbackQueryHandlers(deps)
619
+ const { ctx, raw } = makeCtx()
620
+ await h.handleVaultGrantCallback(ctx, 'vg:cancel')
621
+ expect(deps.recordGrantWizardKernelDecision).toHaveBeenCalledWith('kr_9', 'deny', 111, [
622
+ '111',
623
+ '222',
624
+ ])
625
+ expect(deps.pendingVaultOps.has('111')).toBe(false)
626
+ expect(raw.editMessageText).toHaveBeenCalledWith('❌ Grant wizard cancelled.')
627
+ })
628
+
629
+ it('a wizard tap with no session edits to the expired notice', async () => {
630
+ const { deps } = makeDeps()
631
+ const h = createCallbackQueryHandlers(deps)
632
+ const { ctx, raw } = makeCtx()
633
+ await h.handleVaultGrantCallback(ctx, 'vg:keys-continue')
634
+ expect(raw.editMessageText).toHaveBeenCalledWith(
635
+ '⚠️ Wizard session expired. Run /vault grant to start again.',
636
+ )
637
+ })
638
+
639
+ it('vg:key toggles selection and re-renders the keyboard', async () => {
640
+ const { deps } = makeDeps()
641
+ deps.pendingVaultOps.set('111', {
642
+ kind: 'grant-wizard',
643
+ step: 'keys',
644
+ agent: 'worker',
645
+ selectedKeys: [],
646
+ availableKeys: ['a', 'b'],
647
+ wizardMsgId: 42,
648
+ startedAt: Date.now(),
649
+ })
650
+ const h = createCallbackQueryHandlers(deps)
651
+ const { ctx, raw } = makeCtx()
652
+ await h.handleVaultGrantCallback(ctx, 'vg:key:a')
653
+ const st = deps.pendingVaultOps.get('111')
654
+ expect(st?.kind).toBe('grant-wizard')
655
+ if (st?.kind === 'grant-wizard') expect(st.selectedKeys).toEqual(['a'])
656
+ expect(raw.editMessageReplyMarkup).toHaveBeenCalled()
657
+ })
658
+
659
+ it('vg:keys-continue with nothing selected toasts instead of advancing', async () => {
660
+ const { deps } = makeDeps()
661
+ deps.pendingVaultOps.set('111', {
662
+ kind: 'grant-wizard',
663
+ step: 'keys',
664
+ agent: 'worker',
665
+ selectedKeys: [],
666
+ availableKeys: ['a'],
667
+ wizardMsgId: 42,
668
+ startedAt: Date.now(),
669
+ })
670
+ const h = createCallbackQueryHandlers(deps)
671
+ const { ctx, raw } = makeCtx()
672
+ await h.handleVaultGrantCallback(ctx, 'vg:keys-continue')
673
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Select at least one key.' })
674
+ const st = deps.pendingVaultOps.get('111')
675
+ if (st?.kind === 'grant-wizard') expect(st.step).toBe('keys')
676
+ })
677
+ })
678
+
679
+ // ── Cross-cutting: every mutating family enforces the allowFrom gate ────
680
+
681
+ describe('authorization gate parity', () => {
682
+ const routes: Array<[string, (h: ReturnType<typeof createCallbackQueryHandlers>, ctx: Context) => Promise<void>]> = [
683
+ ['vrd', (h, ctx) => h.handleVaultRecentDenialCallback(ctx, 'vrd:worker:k')],
684
+ ['vra', (h, ctx) => h.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')],
685
+ ['vrs', (h, ctx) => h.handleVaultRequestSaveCallback(ctx, 'vrs:save:s1')],
686
+ ['vd', (h, ctx) => h.handleVaultDeferCallback(ctx, 'vd:unlock:111:7')],
687
+ ['vg', (h, ctx) => h.handleVaultGrantCallback(ctx, 'vg:cancel')],
688
+ ['mmp', (h, ctx) => h.handleMentalModelProposeCallback(ctx, 'mmp:approve:m1')],
689
+ ['sp', (h, ctx) => h.handleSkillProposalCallback(ctx, 'sp:approve:p1')],
690
+ ['op', (h, ctx) => h.handleOperatorEventCallback(ctx, 'op:dismiss:worker')],
691
+ ]
692
+ for (const [name, run] of routes) {
693
+ it(`${name}:* refuses a non-allowlisted sender`, async () => {
694
+ const { deps } = makeDeps()
695
+ const h = createCallbackQueryHandlers(deps)
696
+ const { ctx, raw } = makeCtx({ senderId: '999' })
697
+ await run(h, ctx)
698
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
699
+ })
700
+ }
701
+ })