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,2660 @@
1
+ // Callback-query handlers — the vault / skill / mental-model / operator-event /
2
+ // auth-dashboard callback families, extracted verbatim from gateway.ts
3
+ // (#2996 Phase 5, remaining item 2).
4
+ //
5
+ // This module owns the handler LOGIC for the `bot.on('callback_query:data')`
6
+ // families that were previously ~2,200 lines of free functions inside
7
+ // gateway.ts:
8
+ //
9
+ // - vrd:* — /vault audit one-tap "Always allow" (recent denials)
10
+ // - vra:* — vault_request_access approve/deny (+ performVaultAccessApproval,
11
+ // shared with the passphrase-capture resume flow)
12
+ // - vrs:* — vault_request_save save/discard/rename
13
+ // - vd:* — deferred-secret unlock/cancel (+ executeDeferredSecretSave,
14
+ // shared with the passphrase text-intercept)
15
+ // - vg:* — vault grant management + the /vault grant wizard (all steps)
16
+ // - mmp:* — mental-model proposal approve/deny
17
+ // - sp:* — skill-improvement proposal approve/dismiss
18
+ // - op:* — operator-event card actions (dismiss/restart/reauth/logs)
19
+ // - auth:* — auth dashboard (fleet account swap, snapshot refresh)
20
+ //
21
+ // Deliberately NOT moved (they stay in gateway.ts): the dispatcher itself
22
+ // (`bot.on('callback_query:data', …)` — it also routes agent:*, mdl:*, eff:*,
23
+ // cn:*, cfg:*, and permission-card callbacks whose state is entangled with
24
+ // the durability layer), the card RENDER/stage paths (vault_request_* MCP
25
+ // tool handlers), and the pending-store constructions (TTL sweeps + expiry
26
+ // thunks live with the reaper).
27
+ //
28
+ // Style: factory over a deps object, following outbound-send-path.ts /
29
+ // register-bot-commands.ts / pending-state-stores.ts (PRs #3007-#3011).
30
+ // Everything gateway-LOCAL (module state, stores, config-derived mutable
31
+ // flags, wrapped API callers) is injected; everything gateway itself imports
32
+ // from other modules is imported here directly. Function bodies are
33
+ // byte-identical to the pre-extraction gateway.ts text except for four
34
+ // mechanical reads of formerly-mutable module `let`s, which became injected
35
+ // getters (`getVaultApprovalAuthMode()`, `getAdminOnlyKeys()`) so config
36
+ // reloads keep being observed live.
37
+
38
+ import { execFileSync } from 'child_process'
39
+ import { mkdirSync, writeFileSync } from 'fs'
40
+ import { homedir } from 'os'
41
+ import { join } from 'path'
42
+ import { InlineKeyboard, type Context } from 'grammy'
43
+ import { richMessage } from '../rich-send.js'
44
+ import { finalizeCallback } from '../inline-keyboard-callbacks.js'
45
+ import { retryWithThreadFallback, type RetryCallOpts } from '../retry-api-call.js'
46
+ import {
47
+ mintGrantViaBroker,
48
+ listViaBroker,
49
+ listGrantsViaBroker,
50
+ revokeGrantViaBroker,
51
+ } from '../../src/vault/broker/client.js'
52
+ import {
53
+ buildVaultGrantApprovedInbound,
54
+ buildVaultGrantApprovedCardText,
55
+ buildVaultGrantDeniedInbound,
56
+ buildVaultSaveCompletedInbound,
57
+ buildVaultSaveFailedInbound,
58
+ buildVaultSaveDiscardedInbound,
59
+ } from './vault-grant-inbound-builders.js'
60
+ import {
61
+ resolveMentalModelProposal,
62
+ type MentalModelPendingProposal,
63
+ } from './mental-model-propose-resolve.js'
64
+ import {
65
+ parseSkillProposalCallback,
66
+ buildSkillProposalApplyInbound,
67
+ } from './skill-proposal-card.js'
68
+ import {
69
+ getProposal as getSkillProposal,
70
+ setProposalStatus as setSkillProposalStatus,
71
+ } from '../../src/self-improve/skill-proposals.js'
72
+ import { maskToken } from '../secret-detect/mask.js'
73
+ import {
74
+ defaultVaultWrite,
75
+ defaultVaultList,
76
+ defaultVaultWritePosture,
77
+ } from '../secret-detect/vault-write.js'
78
+ import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
79
+ import type { StagingMap } from '../secret-detect/staging.js'
80
+ import { zipProbeResults } from '../auth-snapshot-format.js'
81
+ import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
82
+ import { getAuthBrokerClient } from './auth-broker-client.js'
83
+ import { chatKey } from './chat-key.js'
84
+ import { tryHostdDispatch, hostdRequestId } from './hostd-dispatch.js'
85
+ import type { HostdRequest } from '../../src/host-control/protocol.js'
86
+ import type { InboundMessage } from './ipc-protocol.js'
87
+ import type { SweepableCardStore } from './approval-card-stores.js'
88
+ import type { SweepableStore } from './pending-state-stores.js'
89
+
90
+ // ─── Pending-state entry types (moved with the handlers; the stores that
91
+ // hold them are still constructed in gateway.ts, which imports these
92
+ // types back) ─────────────────────────────────────────────────────────
93
+
94
+ export type PendingVaultOp =
95
+ | { kind: 'passphrase'; op: 'list' | 'get' | 'delete' | 'set'; key?: string; startedAt: number }
96
+ | { kind: 'value'; op: 'set'; key: string; passphrase: string; startedAt: number }
97
+ // Issue #44: passphrase entry triggered by tapping "🔓 Unlock vault & save"
98
+ // on a deferred-secret card. After the passphrase is cached we look up the
99
+ // held secret by deferKey and write it directly — no re-paste required.
100
+ | {
101
+ kind: 'passphrase-for-deferred'
102
+ deferKey: string
103
+ cardChatId: string
104
+ cardMessageId: number
105
+ startedAt: number
106
+ }
107
+ // Issue #158: passphrase collected for /vault unlock — sent directly to the
108
+ // broker unlock socket, never logged or cached beyond the op itself.
109
+ | { kind: 'unlock'; startedAt: number }
110
+ // Issue #227: inline-keyboard wizard for /vault grant
111
+ | {
112
+ kind: 'grant-wizard'
113
+ step: 'agent' | 'keys' | 'duration' | 'confirm'
114
+ wizardMsgId?: number // message to edit for each step
115
+ agent?: string
116
+ selectedKeys?: string[] // keys toggled on in step 2
117
+ availableKeys?: string[] // list fetched from broker
118
+ ttlSeconds?: number | null // null = never expires
119
+ expiresLabel?: string // human-readable label for confirmation
120
+ description?: string
121
+ awaitingCustomDuration?: boolean // true while waiting for text reply
122
+ /**
123
+ * Approval-kernel request_id minted at the wizard confirm step
124
+ * (MIGRATION.md §2, Phase 1 dual-dispatch — audit-only, advisory).
125
+ * When set, `vg:generate` ALSO consumes + records an `allow_once`
126
+ * decision on the kernel; `vg:cancel` records a `deny`. Cards in
127
+ * flight from before this PR landed have it `undefined` and the
128
+ * legacy `mintGrantViaBroker` runs alone — no kernel write. After
129
+ * 1-2 releases the legacy-only branch can be removed (#833 Phase 2
130
+ * is the enforcing flip).
131
+ */
132
+ kernel_request_id?: string
133
+ startedAt: number
134
+ }
135
+ // Issue #228: waiting for confirmation before revoking a grant.
136
+ | { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
137
+ // Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
138
+ // the next message becomes the new key name for the staged save.
139
+ | { kind: 'rename-vault-save'; stageId: string; startedAt: number }
140
+ // Issue #1012 Phase 2 follow-up: operator tapped Approve on a
141
+ // vault_request_access card without first unlocking the vault. The
142
+ // next message becomes the passphrase — we cache it, delete the
143
+ // passphrase message, and auto-resume the approval mint flow without
144
+ // making the operator tap Approve a second time. Mirrors the
145
+ // `passphrase-for-deferred` flow from #44.
146
+ //
147
+ // #1051: `items` is a queue so concurrent Approve taps (operator
148
+ // taps card 2 before typing passphrase for card 1) don't strand
149
+ // earlier stages. On passphrase reply we process all queued items
150
+ // sequentially. Each item carries its own stageId + card refs;
151
+ // they're all in the same chat by construction (pendingVaultOps
152
+ // map is keyed by chat_id).
153
+ | {
154
+ kind: 'passphrase-for-access-approve'
155
+ items: Array<{
156
+ stageId: string
157
+ cardChatId: string
158
+ cardMessageId: number
159
+ senderId: string
160
+ }>
161
+ startedAt: number
162
+ }
163
+
164
+ export interface DeferredSecret {
165
+ chat_id: string
166
+ original_message_id: number
167
+ text: string
168
+ staged_at: number
169
+ /**
170
+ * Slug suggested by the detector at the time we deferred the secret.
171
+ * Captured up-front so the post-unlock auto-write doesn't have to re-run
172
+ * detection (which would have to handle the no-detection-fired case for
173
+ * Channel B context-rule defers — issue #44). Falls back to a generic
174
+ * slug if detection didn't fire.
175
+ */
176
+ suggested_slug: string
177
+ /**
178
+ * Approval-kernel request_id minted alongside the bespoke deferred-secret
179
+ * card (MIGRATION.md §1, Phase 1 dual-dispatch). When set, the
180
+ * `vd:unlock` / `vd:cancel` callback handler ALSO records the user's
181
+ * decision on the kernel side via `approvalConsume` + `approvalRecord`,
182
+ * so the audit log captures the unlock event.
183
+ *
184
+ * `undefined` on cards built before this PR landed (in-flight at deploy
185
+ * time) — the legacy handler runs alone, no kernel record. After ~1-2
186
+ * releases the legacy-only branch can be removed (separate cleanup PR).
187
+ */
188
+ kernel_request_id?: string
189
+ }
190
+
191
+ /**
192
+ * Agent-initiated save staging (issue #969 P1a). When an agent calls the
193
+ * `vault_request_save` MCP tool, we stage the value here, render an
194
+ * approval card to the user, and write to vault only on tap. The value
195
+ * is held in gateway memory ONLY — never echoed back to the agent and
196
+ * never logged.
197
+ */
198
+ export interface PendingVaultRequestSave {
199
+ /** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
200
+ agent: string
201
+ /** Chat to edit when the user taps. */
202
+ chat_id: string
203
+ /** Card message id (filled in after we send the card). */
204
+ card_message_id?: number
205
+ /** Supergroup forum topic the agent was working in when it requested the
206
+ * save — carried into the save-outcome inbound so the resumed reply lands
207
+ * back in that topic, not General. */
208
+ threadId?: number
209
+ /** Currently-suggested slug; may be renamed by the user. */
210
+ key: string
211
+ /** Storage shape — 'string' (default) or 'binary'. */
212
+ kind: 'string' | 'binary'
213
+ /** The secret value, held in memory until the user approves/discards. */
214
+ value: string
215
+ /** Optional rationale shown on the card. */
216
+ why?: string
217
+ /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
218
+ staged_at: number
219
+ /** Set on entries RESTORED from disk after a gateway restart. The staged
220
+ * secret `value` is held in memory only (never persisted — secrets
221
+ * hygiene), so a restored entry has an empty value and cannot complete the
222
+ * write. A Save tap on such a card degrades gracefully: it tells the agent
223
+ * the value was lost to a restart instead of writing an empty secret. */
224
+ restoredWithoutValue?: boolean
225
+ }
226
+
227
+ /**
228
+ * Issue #1012 — agent-initiated vault ACL request. The agent calls
229
+ * `vault_request_access` when it hits VAULT-BROKER-DENIED (or
230
+ * preemptively, when it knows it'll need a key it doesn't yet have).
231
+ * The card carries [Approve] / [Deny] inline buttons; only the
232
+ * operator can mint the grant (same authorization gate as the
233
+ * existing /vault audit one-tap allow flow). The agent never sees
234
+ * the grant token directly — `mintGrantViaBroker` writes it to the
235
+ * agent's `.vault-token` file, which the agent's CLI reads on the
236
+ * next vault request.
237
+ *
238
+ * Mirrors PendingVaultRequestSave above (#969 P1a). No secret
239
+ * material is staged here — only the request metadata.
240
+ */
241
+ export interface PendingVaultRequestAccess {
242
+ /** Agent that initiated the request (process.env.SWITCHROOM_AGENT_NAME). */
243
+ agent: string
244
+ /** Chat the card was rendered into; edited on tap. */
245
+ chat_id: string
246
+ /** Card message id (filled in after we send the card). */
247
+ card_message_id?: number
248
+ /** Supergroup forum topic the agent was working in when it requested (the
249
+ * card's originating thread). Carried into the grant-outcome inbound so the
250
+ * resumed reply lands back in that topic, not General. */
251
+ threadId?: number
252
+ /** Vault key the agent wants to read. */
253
+ key: string
254
+ /** 'read' (default) or 'write'. */
255
+ scope: 'read' | 'write'
256
+ /** Optional rationale the agent supplied; rendered on the card. */
257
+ reason?: string
258
+ /** Grant TTL in seconds (max 90 days; null = never, refused). */
259
+ ttl_seconds: number
260
+ /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_ACCESS_TTL_MS. */
261
+ staged_at: number
262
+ }
263
+
264
+ /**
265
+ * Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
266
+ * calls `mental_model_propose`; the operator taps Approve/Deny on the card.
267
+ * Mirrors PendingVaultRequestAccess — no memory content is staged here, only
268
+ * the proposed DECLARATION (name + source_query + optional knobs). On Approve
269
+ * the model becomes a first-class declared model in memory.mental_models[] via
270
+ * the operator-approved config-edit path; on Deny nothing is written.
271
+ */
272
+ export interface PendingMentalModelPropose {
273
+ agent: string
274
+ chat_id: string
275
+ card_message_id?: number
276
+ threadId?: number
277
+ /** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
278
+ spec: {
279
+ name: string
280
+ source_query: string
281
+ refresh_after_consolidation?: boolean
282
+ max_tokens?: number
283
+ }
284
+ reason?: string
285
+ staged_at: number
286
+ }
287
+
288
+ // ─── Deps ────────────────────────────────────────────────────────────────
289
+
290
+ /** Minimal bot shape the handlers touch (grammy Bot / chat-locked wrapper). */
291
+ export interface CallbackBotApi {
292
+ api: {
293
+ editMessageText: (
294
+ chat_id: number | string,
295
+ message_id: number,
296
+ text: unknown,
297
+ other?: Record<string, unknown>,
298
+ ) => Promise<unknown>
299
+ sendRichMessage: (
300
+ chat_id: number | string,
301
+ rich_message: unknown,
302
+ other?: Record<string, unknown>,
303
+ ) => Promise<{ message_id: number }>
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Everything the handlers read from gateway module scope. Stores are the
309
+ * consolidated #3008/#3011 store surfaces — never raw Maps. Mutable config
310
+ * `let`s (`VAULT_APPROVAL_AUTH_MODE`, `ADMIN_ONLY_KEYS`) are injected as
311
+ * getters so late config assignment stays observable.
312
+ */
313
+ export interface CallbackQueryHandlersDeps {
314
+ /** The gateway's grammy bot singleton (raw sends on fallback paths). */
315
+ bot: unknown
316
+ /** The chat-lock-wrapped bot (serialized sends; passphrase prompt path). */
317
+ lockedBot: unknown
318
+ /** Read the live access file (allowFrom gate on every mutating family). */
319
+ loadAccess: () => { allowFrom: string[] }
320
+ escapeHtmlForTg: (text: string) => string
321
+ switchroomReply: (
322
+ ctx: Context,
323
+ text: string,
324
+ options?: {
325
+ html?: boolean
326
+ reply_markup?:
327
+ | InlineKeyboard
328
+ | { force_reply: true; input_field_placeholder?: string; selective?: boolean }
329
+ classification?: 'query' | 'mutation' | 'heavy'
330
+ },
331
+ ) => Promise<unknown>
332
+ resolveThreadId: (chat_id: string, explicit?: string | number | null) => number | undefined
333
+ deliverResumeSyntheticOrBuffer: (agent: string, inbound: InboundMessage) => boolean
334
+ expireMentalModelProposeCard: (stageId: string, v: PendingMentalModelPropose, now: number) => void
335
+ readLiveSwitchroomConfigText: () => string
336
+ mentalModelCorrelationKey: (agentName: string, unifiedDiff: string) => string
337
+ getMyAgentName: () => string
338
+ triggerSelfRestart: (targetAgent: string, reason: string, delayMs?: number) => boolean
339
+ runSwitchroomAuthCommand: (ctx: Context, args: string[], label: string) => Promise<void>
340
+ switchroomExecJson: <T = unknown>(args: string[]) => T | null
341
+ assertSafeAgentName: (name: string) => void
342
+ buildDeferredSecretKeyboard: (deferKey: string) => InlineKeyboard
343
+ recordDeferredSecretKernelDecision: (
344
+ request_id: string | undefined,
345
+ decision: 'allow_once' | 'deny',
346
+ granted_by_user_id: number,
347
+ approverSet: string[],
348
+ ) => Promise<void>
349
+ mintGrantWizardKernelRequest: (
350
+ agentSlug: string,
351
+ approverSet: string[],
352
+ selectedKeys: string[],
353
+ ttlSeconds: number | null,
354
+ ) => Promise<string | null>
355
+ recordGrantWizardKernelDecision: (
356
+ request_id: string | undefined,
357
+ decision: 'allow_once' | 'deny',
358
+ granted_by_user_id: number,
359
+ approverSet: string[],
360
+ ) => Promise<void>
361
+ /** Flood-wait-aware retry wrapper (gateway's `robustApiCall`). */
362
+ robustApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T>
363
+ /** Fire-and-forget retry wrapper (gateway's `swallowingApiCall`, #1075). */
364
+ swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
365
+ // Pending-state stores (consolidated surfaces from #3008/#3011).
366
+ pendingVaultRequestAccesses: SweepableCardStore<PendingVaultRequestAccess>
367
+ pendingVaultRequestSaves: SweepableCardStore<PendingVaultRequestSave>
368
+ pendingMentalModelProposes: SweepableCardStore<PendingMentalModelPropose>
369
+ pendingCardStore: { remove(stageId: string): void }
370
+ pendingMentalModelCorrelations: SweepableStore<{
371
+ agentName: string
372
+ unifiedDiff: string
373
+ createdAt: number
374
+ }>
375
+ pendingVaultOps: SweepableStore<PendingVaultOp>
376
+ vaultPassphraseCache: SweepableStore<{ passphrase: string; expiresAt: number }>
377
+ deferredSecrets: SweepableStore<DeferredSecret>
378
+ pendingReauthFlows: SweepableStore<{ agent: string; startedAt: number }>
379
+ secretStaging: StagingMap
380
+ /** Refresh throttle timestamps for the /auth dashboard ↻ button (the
381
+ * 60s reaper for this map stays in gateway.ts). */
382
+ lastAuthRefreshAtMs: Map<string, number>
383
+ /** Live read of the mutable `VAULT_APPROVAL_AUTH_MODE` module let. */
384
+ getVaultApprovalAuthMode: () => 'passphrase' | 'telegram-id'
385
+ /** Live read of the mutable `ADMIN_ONLY_KEYS` module let. */
386
+ getAdminOnlyKeys: () => string[]
387
+ /** Gateway-side vault-key shape gate (VAULT_KEY_REGEX; UX gate, not a
388
+ * security boundary). */
389
+ vaultKeyRegex: RegExp
390
+ /** MENTAL_MODEL_PROPOSE_TTL_MS (config-driven approval-card lifetime). */
391
+ mentalModelProposeTtlMs: number
392
+ }
393
+
394
+ // Freshness throttle for the /auth dashboard ↻ refresh button — one live
395
+ // probe fan-out per (chat, message) per window. Moved with the handler.
396
+ const AUTH_REFRESH_THROTTLE_MS = 5_000
397
+
398
+ /**
399
+ * Build the callback-query handler families over the injected gateway deps.
400
+ * Bodies are verbatim from gateway.ts — behavior-preserving (#2996).
401
+ */
402
+ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
403
+ const {
404
+ loadAccess,
405
+ escapeHtmlForTg,
406
+ switchroomReply,
407
+ resolveThreadId,
408
+ deliverResumeSyntheticOrBuffer,
409
+ expireMentalModelProposeCard,
410
+ readLiveSwitchroomConfigText,
411
+ mentalModelCorrelationKey,
412
+ getMyAgentName,
413
+ triggerSelfRestart,
414
+ runSwitchroomAuthCommand,
415
+ switchroomExecJson,
416
+ assertSafeAgentName,
417
+ buildDeferredSecretKeyboard,
418
+ recordDeferredSecretKernelDecision,
419
+ mintGrantWizardKernelRequest,
420
+ recordGrantWizardKernelDecision,
421
+ robustApiCall,
422
+ swallowingApiCall,
423
+ pendingVaultRequestAccesses,
424
+ pendingVaultRequestSaves,
425
+ pendingMentalModelProposes,
426
+ pendingCardStore,
427
+ pendingMentalModelCorrelations,
428
+ pendingVaultOps,
429
+ vaultPassphraseCache,
430
+ deferredSecrets,
431
+ pendingReauthFlows,
432
+ secretStaging,
433
+ lastAuthRefreshAtMs,
434
+ getVaultApprovalAuthMode,
435
+ getAdminOnlyKeys,
436
+ vaultKeyRegex: VAULT_KEY_REGEX,
437
+ mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
438
+ } = deps
439
+ const bot = deps.bot as CallbackBotApi
440
+ const lockedBot = deps.lockedBot as CallbackBotApi
441
+
442
+ /**
443
+ * Handle a callback_query from an auth dashboard button. Parses the
444
+ * callback_data, runs the matching action, acknowledges the tap with a
445
+ * toast, and refreshes the dashboard in-place via editMessageText.
446
+ */
447
+ /**
448
+ * Handle op:<action>:<encoded-agent> callbacks from operator-events.ts
449
+ * renderOperatorEvent(). Phase 4b — closes the "buttons do nothing" gap.
450
+ *
451
+ * Actions:
452
+ * dismiss — clear keyboard + toast
453
+ * restart — systemctl --user restart switchroom-<agent>
454
+ * reauth — delegate to runSwitchroomAuthCommand (same flow as /auth reauth)
455
+ * logs — post last 30 lines of journalctl for the agent
456
+ * slot management buttons — removed (E5); use /auth use or /auth add instead.
457
+ */
458
+ /**
459
+ * Issue #44: handle taps on the deferred-secret card's inline buttons.
460
+ *
461
+ * `vd:unlock:<deferKey>` — register a `passphrase-for-deferred` pending
462
+ * vault op and edit the card to ask the user for their passphrase.
463
+ * The text-handler picks the passphrase up via the existing
464
+ * pendingVaultOps intercept and calls `executeDeferredSecretSave`
465
+ * to write the held secret directly. No re-paste required.
466
+ *
467
+ * `vd:cancel:<deferKey>` — drop the deferred secret and clear the card.
468
+ * The held bytes are evicted from the in-memory `deferredSecrets`
469
+ * map (they were never written to disk) so the secret vanishes.
470
+ *
471
+ * Authorization mirrors the operator-event callback: only senders on the
472
+ * configured allowlist get to act on the buttons.
473
+ */
474
+ /**
475
+ * Issue #969 P1a — handle the agent-initiated vault-save approval card
476
+ * (`vault_request_save` MCP tool).
477
+ *
478
+ * Callbacks:
479
+ * vrs:save:<stageId> — confirm save; write to vault using broker put
480
+ * with operator-passphrase attestation (#969 P1a)
481
+ * so even new keys go through in one tap.
482
+ * vrs:discard:<stageId> — drop the staged secret; never touches disk.
483
+ * vrs:rename:<stageId> — set up a pending-op intercept so the user's
484
+ * next message is taken as a new key name.
485
+ */
486
+ /**
487
+ * Issue #969 P2b — handle a tap on the "🔓 Allow <key>" button posted by
488
+ * `/vault audit <agent>`'s Recent denials section. Mints a 30-day
489
+ * read-grant for the agent + key via the broker.
490
+ *
491
+ * The grant also unioning into the agent's existing token if one is
492
+ * already present is out of scope for this PR — the operator can
493
+ * re-mint with a wider key list if they want consolidation.
494
+ */
495
+ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Promise<void> {
496
+ const senderId = String(ctx.from?.id ?? '')
497
+ const access = loadAccess()
498
+ if (!access.allowFrom.includes(senderId)) {
499
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
500
+ return
501
+ }
502
+ // vrd:<agent>:<key> — parse, validate both halves against the strict
503
+ // slug regex before doing anything else.
504
+ const parts = data.split(':')
505
+ if (parts.length !== 3) {
506
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
507
+ return
508
+ }
509
+ const [, agentName, keyName] = parts
510
+ if (!/^[a-z][a-z0-9-]{0,62}$/i.test(agentName)) {
511
+ await ctx.answerCallbackQuery({ text: 'Invalid agent name' }).catch(() => {})
512
+ return
513
+ }
514
+ // #1047: same canonical key shape as vault_request_save /
515
+ // vault_request_access — namespaced keys like `fatsecret/client_id`
516
+ // must round-trip through the /vault audit one-tap Allow flow too,
517
+ // not just the agent-initiated approval cards.
518
+ if (!VAULT_KEY_REGEX.test(keyName)) {
519
+ await ctx.answerCallbackQuery({ text: 'Invalid key name' }).catch(() => {})
520
+ return
521
+ }
522
+ await ctx.answerCallbackQuery({ text: '⏳ Minting 30-day read grant…' }).catch(() => {})
523
+
524
+ const result = await mintGrantViaBroker({
525
+ agent: agentName,
526
+ keys: [keyName],
527
+ ttl_seconds: 30 * 24 * 60 * 60,
528
+ description: `auto-mint via /vault audit one-tap (#969 P2b)`,
529
+ })
530
+
531
+ if (result.kind === 'unreachable') {
532
+ await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
533
+ return
534
+ }
535
+ if (result.kind === 'error') {
536
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
537
+ return
538
+ }
539
+ // Write the token to the agent's .vault-token file — same flow as the
540
+ // vault grant wizard. The agent restarts in the background pick up
541
+ // the new token via SWITCHROOM_AGENT_NAME on next CLI invocation.
542
+ const { token, id } = result
543
+ const tokenPath = join(homedir(), '.switchroom', 'agents', agentName, '.vault-token')
544
+ try {
545
+ mkdirSync(join(homedir(), '.switchroom', 'agents', agentName), { recursive: true })
546
+ writeFileSync(tokenPath, token, { mode: 0o600 })
547
+ } catch (err) {
548
+ await switchroomReply(
549
+ ctx,
550
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
551
+ `${escapeHtmlForTg(String(err))}\n` +
552
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
553
+ `--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
554
+ { html: true },
555
+ )
556
+ return
557
+ }
558
+ // #1150 audit: P0 fix — pre-fix the audit-listing message kept its
559
+ // tappable [Always allow ...] buttons after a successful mint, so
560
+ // the operator could re-tap the same denial and re-mint the grant
561
+ // (broker idempotency saves us from a duplicate write but the
562
+ // operator experience was "did anything happen? let me tap again").
563
+ // Strip the entire audit-listing keyboard on first tap + append a
564
+ // status line so the action is visible. Operator re-runs `/vault
565
+ // audit` to act on remaining denials — that's the documented flow.
566
+ // HTML-escape the source text before concatenation. `ctx.callbackQuery.
567
+ // message.text` returns the body with entities STRIPPED (Telegram
568
+ // decodes the original HTML), so any raw `<`, `>`, `&` in agent/key
569
+ // names that survived the original audit-listing's escape pass would
570
+ // now break the HTML re-parse — and finalizeCallback's catch swallows
571
+ // the failure, leaving the keyboard tappable. Caught in PR #1158 review
572
+ // for the operator-event card; the same fix applies here.
573
+ const sourceMsg = ctx.callbackQuery?.message
574
+ const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
575
+ ? escapeHtmlForTg(sourceMsg.text)
576
+ : ''
577
+ const statusLine =
578
+ `\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
579
+ `\`${keyName}\` for 30 days ` +
580
+ `(grant \`${id}\`). ` +
581
+ `Re-run /vault audit to act on remaining denials.`
582
+ await finalizeCallback(ctx, {
583
+ ackText: '✅ Grant minted',
584
+ newText: baseText ? `${baseText}${statusLine}` : statusLine,
585
+ // No synthInbound — operator-only flow. The granted agent picks
586
+ // up the token via .vault-token file on next CLI invocation; no
587
+ // turn-wake needed.
588
+ })
589
+ }
590
+
591
+ /**
592
+ * Issue #1012 — handle a tap on the vault_request_access approval card.
593
+ * vra:approve:<stageId> — mint a scoped grant token via the broker,
594
+ * write the token to the agent's
595
+ * `.vault-token` file, edit card to success.
596
+ * vra:deny:<stageId> — drop the staged request, edit card to denied.
597
+ *
598
+ * Same authorization gate as the recent-denials one-tap handler:
599
+ * sender must be on the gateway's allowFrom list.
600
+ */
601
+ /**
602
+ * Mint the scoped grant + write the token file for an approved
603
+ * `vault_request_access` request. Factored out so both the direct
604
+ * approve-tap (passphrase already cached) and the
605
+ * `passphrase-for-access-approve` resume flow (passphrase captured
606
+ * via text-message intercept after tap-on-locked) drive identical
607
+ * minting behaviour. #1012 Phase 2 + follow-up.
608
+ */
609
+ /**
610
+ * #1115 follow-up: caller-supplied attestation. Either a real operator
611
+ * passphrase (when the operator typed it in chat) or a posture flag
612
+ * that tells the broker to use its own retained passphrase under
613
+ * `vault.broker.approvalAuth: telegram-id`. The passphrase variant
614
+ * never crosses into telegram-id callsites; the posture variant
615
+ * never crosses into passphrase-mode callsites.
616
+ */
617
+ type AccessApprovalAttestation =
618
+ | { kind: 'passphrase'; passphrase: string }
619
+ | { kind: 'posture' }
620
+
621
+ async function performVaultAccessApproval(
622
+ ctx: Context,
623
+ pending: PendingVaultRequestAccess,
624
+ stageId: string,
625
+ senderId: string,
626
+ attestation: AccessApprovalAttestation,
627
+ ): Promise<void> {
628
+ const brokerAuthOpts =
629
+ attestation.kind === 'passphrase'
630
+ ? { passphrase: attestation.passphrase }
631
+ : { attest_via_posture: true as const }
632
+
633
+ // Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
634
+ // card staged before the key became standing-ACL-covered (config edit
635
+ // / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
636
+ // this read key, do NOT mint — minting writes a `.vault-token` that
637
+ // shadows the standing ACL and is redundant. Authoritative broker
638
+ // probe AS THIS AGENT (no-token list over the per-agent socket — same
639
+ // rationale as executeVaultRequestAccess; never a gateway-side config
640
+ // read). Read scope only. Fail-open on probe error (mint as before).
641
+ if (pending.scope === 'read') {
642
+ try {
643
+ const visible = await listViaBroker()
644
+ if (visible !== null && visible.includes(pending.key)) {
645
+ pendingVaultRequestAccesses.delete(stageId)
646
+ pendingCardStore.remove(stageId)
647
+ if (pending.card_message_id != null) {
648
+ await ctx.api
649
+ .editMessageText(
650
+ pending.chat_id,
651
+ pending.card_message_id,
652
+ `ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
653
+ `\`${pending.key}\` (schedule.secrets[]). ` +
654
+ `**No grant minted** — a token would shadow the standing ACL. ` +
655
+ richMessage(`The agent can read it directly.`),
656
+ { reply_markup: { inline_keyboard: [] } },
657
+ )
658
+ .catch(() => {})
659
+ }
660
+ return
661
+ }
662
+ } catch {
663
+ // Probe failed: fall through and mint as before (fail-open).
664
+ }
665
+ }
666
+
667
+ // #1051: union the new key with the agent's existing active grant
668
+ // before minting. Without this, each fresh Approve OVERWRITES the
669
+ // agent's `.vault-token` file with a single-key grant — the
670
+ // previous approval's grant is still in the broker DB but the
671
+ // agent can no longer authenticate against it (the CLI reads the
672
+ // file's current token, the broker validates it, sees the new key
673
+ // isn't in the OLD grant's key_allow, and denies).
674
+ //
675
+ // Solution: list the agent's existing non-expired grants
676
+ // (passphrase-attested per #1051's broker-side gate widening),
677
+ // find the active read-grant (most recent non-revoked,
678
+ // non-expired), and pass its keys ∪ new_key as `keys` to the
679
+ // mint call. Old grant ages out via TTL — no explicit revoke
680
+ // needed.
681
+ let existingReadKeys: string[] = [];
682
+ let existingWriteKeys: string[] = [];
683
+ if (pending.scope === 'read' || pending.scope === 'write') {
684
+ const list = await listGrantsViaBroker(pending.agent, brokerAuthOpts);
685
+ if (list.kind === 'ok') {
686
+ const now = Math.floor(Date.now() / 1000);
687
+ // Prefer the MOST RECENT non-revoked, non-expired grant. The
688
+ // broker's listGrants returns ALL non-revoked, but we still
689
+ // filter expires_at locally as defence-in-depth + sort by
690
+ // created_at desc for stability.
691
+ const active = list.grants
692
+ .filter((g) => g.expires_at === null || g.expires_at > now)
693
+ // Reviewer-flagged on #1058 (Q4): `created_at` is
694
+ // seconds-granularity, so two grants minted in the same
695
+ // wall-clock second tie. Secondary sort by `id` (vg_<6hex>)
696
+ // makes the ordering stable so item 2's drain reliably picks
697
+ // up item 1's just-minted grant rather than an unrelated
698
+ // same-second grant.
699
+ .sort((a, b) => {
700
+ const dt = (b.created_at ?? 0) - (a.created_at ?? 0);
701
+ if (dt !== 0) return dt;
702
+ return b.id.localeCompare(a.id);
703
+ });
704
+ if (active.length > 0) {
705
+ existingReadKeys = active[0]!.key_allow ?? [];
706
+ existingWriteKeys = active[0]!.write_allow ?? [];
707
+ }
708
+ }
709
+ // If list fails (broker unreachable / error), proceed without
710
+ // union — better to mint a single-key grant than fail closed
711
+ // entirely. The agent loses the prior coverage in that edge
712
+ // case, same as today, but the new key is granted.
713
+ }
714
+
715
+ // Compute the unioned key sets. Use Set to dedupe.
716
+ const readKeys = new Set<string>(existingReadKeys);
717
+ const writeKeys = new Set<string>(existingWriteKeys);
718
+ if (pending.scope === 'read') readKeys.add(pending.key);
719
+ if (pending.scope === 'write') writeKeys.add(pending.key);
720
+
721
+ const mintArgs: Parameters<typeof mintGrantViaBroker>[0] = {
722
+ agent: pending.agent,
723
+ keys: Array.from(readKeys),
724
+ ttl_seconds: pending.ttl_seconds,
725
+ description:
726
+ `auto-mint via vault_request_access (#1012, scope=${pending.scope}, by op ${senderId}` +
727
+ (existingReadKeys.length + existingWriteKeys.length > 0
728
+ ? `, unioned with prior grant`
729
+ : ``) +
730
+ `)`,
731
+ ...(writeKeys.size > 0 ? { write_keys: Array.from(writeKeys) } : {}),
732
+ ...brokerAuthOpts,
733
+ }
734
+ const result = await mintGrantViaBroker(mintArgs)
735
+ if (result.kind === 'unreachable') {
736
+ await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
737
+ return
738
+ }
739
+ if (result.kind === 'error') {
740
+ // Mint refused (most likely wrong passphrase). Drop the staged
741
+ // request so a re-attempt starts cleanly. The operator can ask
742
+ // the agent to re-issue, or the broker error message will tell
743
+ // them the next step.
744
+ pendingVaultRequestAccesses.delete(stageId)
745
+ pendingCardStore.remove(stageId)
746
+ if (pending.card_message_id != null) {
747
+ await ctx.api
748
+ .editMessageText(
749
+ pending.chat_id,
750
+ pending.card_message_id,
751
+ richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
752
+ { reply_markup: { inline_keyboard: [] } },
753
+ )
754
+ .catch(() => {})
755
+ }
756
+ return
757
+ }
758
+
759
+ const { token, id } = result
760
+ const tokenPath = join(homedir(), '.switchroom', 'agents', pending.agent, '.vault-token')
761
+ try {
762
+ mkdirSync(join(homedir(), '.switchroom', 'agents', pending.agent), { recursive: true })
763
+ writeFileSync(tokenPath, token, { mode: 0o600 })
764
+ } catch (err) {
765
+ await switchroomReply(
766
+ ctx,
767
+ `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
768
+ `${escapeHtmlForTg(String(err))}\n` +
769
+ `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
770
+ `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
771
+ { html: true },
772
+ )
773
+ return
774
+ }
775
+
776
+ pendingVaultRequestAccesses.delete(stageId)
777
+ pendingCardStore.remove(stageId)
778
+ if (pending.card_message_id != null) {
779
+ const days = Math.round(pending.ttl_seconds / 86400)
780
+ const footer =
781
+ getVaultApprovalAuthMode() === 'telegram-id'
782
+ ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
783
+ : ''
784
+ await ctx.api
785
+ .editMessageText(
786
+ pending.chat_id,
787
+ pending.card_message_id,
788
+ richMessage(
789
+ buildVaultGrantApprovedCardText({
790
+ agentEscaped: escapeHtmlForTg(pending.agent),
791
+ scope: pending.scope,
792
+ key: pending.key,
793
+ days,
794
+ grantId: id,
795
+ footer,
796
+ }),
797
+ ),
798
+ { reply_markup: { inline_keyboard: [] } },
799
+ )
800
+ .catch(() => {})
801
+ }
802
+
803
+ // #1052: deliver a synthetic inbound message back to the agent so
804
+ // the task that fired vault_request_access auto-resumes — without
805
+ // this, the agent's turn ended after the tool call ("waiting for
806
+ // approval") and the operator has to send a fresh message to kick
807
+ // it back into action.
808
+ //
809
+ // Uses the existing inject_inbound primitive (cron's pattern from
810
+ // dispatch.ts:180-206). The bridge sees a normal channel event,
811
+ // renders it as `<channel source="vault_grant_approved">`, and the
812
+ // agent starts a new turn with the context that the operator just
813
+ // approved.
814
+ //
815
+ // The synthetic message text is concise + actionable so the agent
816
+ // knows (a) which key was approved, (b) at what scope, (c) what to
817
+ // do next. Meta carries the structured fields for forensics + for
818
+ // future filters that want to suppress these in the chat tail.
819
+ const synthetic = buildVaultGrantApprovedInbound({
820
+ ctx: {
821
+ agent: pending.agent,
822
+ key: pending.key,
823
+ scope: pending.scope,
824
+ chat_id: pending.chat_id,
825
+ ttl_seconds: pending.ttl_seconds,
826
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
827
+ },
828
+ grantId: id,
829
+ stageId,
830
+ operatorId: senderId,
831
+ })
832
+ // Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
833
+ // (flushed at turn-end) so the resume never strands in claude's
834
+ // composer (#1556); idle → deliver; bridge-down → buffer (#1150).
835
+ const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
836
+ process.stderr.write(
837
+ `telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
838
+ `key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
839
+ )
840
+ }
841
+
842
+ /**
843
+ * #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
844
+ * proposal card.
845
+ *
846
+ * Approve → mark the proposal approved + inject a synthetic
847
+ * `skill_proposal_apply` turn instructing the live agent to
848
+ * write the stored draft through `skill_*_personal` (so the
849
+ * secret-scan pipeline runs; agent never self-applies).
850
+ * Dismiss → mark rejected + write a rejection fingerprint so the weekly
851
+ * synthesis cron doesn't re-propose it.
852
+ */
853
+ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
854
+ const senderId = String(ctx.from?.id ?? '')
855
+ const access = loadAccess()
856
+ if (!access.allowFrom.includes(senderId)) {
857
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
858
+ return
859
+ }
860
+ const parsed = parseSkillProposalCallback(data)
861
+ if (parsed == null) {
862
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
863
+ return
864
+ }
865
+ const stateDir = process.env.TELEGRAM_STATE_DIR
866
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
867
+ if (stateDir == null || stateDir.length === 0) {
868
+ await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
869
+ return
870
+ }
871
+ const proposal = getSkillProposal(stateDir, parsed.id)
872
+ if (proposal == null) {
873
+ await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
874
+ if (ctx.callbackQuery?.message) {
875
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
876
+ }
877
+ return
878
+ }
879
+ if (proposal.status !== 'pending') {
880
+ await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
881
+ if (ctx.callbackQuery?.message) {
882
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
883
+ }
884
+ return
885
+ }
886
+
887
+ const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
888
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
889
+
890
+ if (parsed.action === 'deny') {
891
+ setSkillProposalStatus(stateDir, parsed.id, 'rejected')
892
+ await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
893
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
894
+ await ctx
895
+ .editMessageText(
896
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
897
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
898
+ )
899
+ .catch(() => {})
900
+ }
901
+ return
902
+ }
903
+
904
+ // Approve.
905
+ setSkillProposalStatus(stateDir, parsed.id, 'approved')
906
+ const synthetic = buildSkillProposalApplyInbound({
907
+ ctx: {
908
+ agent,
909
+ chat_id: cbChatId,
910
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
911
+ },
912
+ proposalId: proposal.id,
913
+ skillSlug: proposal.skill_slug,
914
+ isNew: proposal.is_new,
915
+ operatorId: senderId,
916
+ })
917
+ const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
918
+ await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
919
+ if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
920
+ await ctx
921
+ .editMessageText(
922
+ `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
923
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
924
+ )
925
+ .catch(() => {})
926
+ }
927
+ process.stderr.write(
928
+ `telegram gateway: skill_proposal_apply injection agent=${agent} ` +
929
+ `proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
930
+ )
931
+ }
932
+
933
+ /**
934
+ * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
935
+ * mmp:approve:<stageId> — declare the model: append it to the agent's
936
+ * memory.mental_models[] via the operator-approved
937
+ * config-edit path (reused config_propose_edit
938
+ * apply+reconcile; reconcile ensures it), then wake
939
+ * the agent with an "applied" inbound.
940
+ * mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
941
+ * agent with a "denied" inbound.
942
+ *
943
+ * Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
944
+ * can PROPOSE but can never self-approve (identical gate to the vault flow).
945
+ */
946
+ async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
947
+ const senderId = String(ctx.from?.id ?? '')
948
+ const access = loadAccess()
949
+ if (!access.allowFrom.includes(senderId)) {
950
+ // Self-approve is impossible: only an allow-listed operator can resolve
951
+ // the card. A tap from anyone else (incl. a compromised agent identity) is
952
+ // refused here.
953
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
954
+ return
955
+ }
956
+ const parts = data.split(':')
957
+ if (parts.length < 3) {
958
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
959
+ return
960
+ }
961
+ const action = parts[1]
962
+ const stageId = parts.slice(2).join(':')
963
+ const pending = pendingMentalModelProposes.get(stageId)
964
+ if (!pending) {
965
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
966
+ if (ctx.callbackQuery?.message) {
967
+ await ctx.api
968
+ .editMessageText(
969
+ ctx.callbackQuery.message.chat.id,
970
+ ctx.callbackQuery.message.message_id,
971
+ richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
972
+ { reply_markup: { inline_keyboard: [] } },
973
+ )
974
+ .catch(() => {})
975
+ }
976
+ return
977
+ }
978
+ if (action !== 'approve' && action !== 'deny') {
979
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
980
+ return
981
+ }
982
+ // Enforce the TTL at TAP time, not just on the next propose's sweep. Without
983
+ // this, a card left untapped past its TTL is still resolvable if no fresh
984
+ // proposal has run the sweep — an operator could approve a stale proposal.
985
+ if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
986
+ // Expired between post and tap: route through the shared expiry path so the
987
+ // parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
988
+ // the durable store entry is cleared — not just a silent map delete.
989
+ expireMentalModelProposeCard(stageId, pending, Date.now())
990
+ await ctx.answerCallbackQuery({ text: 'Card expired — the agent was notified.' }).catch(() => {})
991
+ return
992
+ }
993
+ // Single-shot: remove the pending entry immediately so a double-tap can't
994
+ // resolve twice.
995
+ pendingMentalModelProposes.delete(stageId)
996
+ pendingCardStore.remove(stageId)
997
+
998
+ const proposal: MentalModelPendingProposal = {
999
+ agent: pending.agent,
1000
+ chat_id: pending.chat_id,
1001
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1002
+ spec: pending.spec,
1003
+ ...(pending.reason ? { reason: pending.reason } : {}),
1004
+ }
1005
+
1006
+ const resolveDeps = {
1007
+ readConfigText: () => readLiveSwitchroomConfigText(),
1008
+ registerPreApproval: (agent: string, diff: string) => {
1009
+ pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
1010
+ agentName: agent,
1011
+ unifiedDiff: diff,
1012
+ createdAt: Date.now(),
1013
+ })
1014
+ },
1015
+ clearPreApproval: (agent: string, diff: string) => {
1016
+ pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
1017
+ },
1018
+ dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
1019
+ const req: HostdRequest = {
1020
+ v: 1,
1021
+ op: 'config_propose_edit',
1022
+ request_id: hostdRequestId('gw-mental-model'),
1023
+ args: {
1024
+ unified_diff: a.diff,
1025
+ reason: a.reason,
1026
+ target_path: '/state/config/switchroom.yaml',
1027
+ },
1028
+ }
1029
+ // config_propose_edit blocks on validate→approve→apply→reconcile
1030
+ // (5-10 min on a busy host) — allow 12 min. The operator already
1031
+ // approved on the proposal card, so hostd's config-approval callback
1032
+ // auto-resolves via the pre-registered correlation (no second card).
1033
+ const resp = await tryHostdDispatch(a.agent, req, 720_000)
1034
+ if (resp === 'not-configured') {
1035
+ return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
1036
+ }
1037
+ if (resp.result === 'completed') return { state: 'applied' as const }
1038
+ if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
1039
+ return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
1040
+ },
1041
+ // Ensure is delegated to reconcile: config_propose_edit's apply triggers a
1042
+ // reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
1043
+ // declared model — the authoritative, correctly-scoped ensure. We
1044
+ // deliberately do NOT add a redundant gateway-side ensure (it would need
1045
+ // the agent's bank id + a reachable Hindsight endpoint from the gateway).
1046
+ injectInbound: (inbound: InboundMessage) => {
1047
+ deliverResumeSyntheticOrBuffer(pending.agent, inbound)
1048
+ },
1049
+ log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
1050
+ }
1051
+
1052
+ if (action === 'deny') {
1053
+ await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
1054
+ await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
1055
+ if (pending.card_message_id != null) {
1056
+ await ctx.api
1057
+ .editMessageText(
1058
+ pending.chat_id,
1059
+ pending.card_message_id,
1060
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
1061
+ { reply_markup: { inline_keyboard: [] } },
1062
+ )
1063
+ .catch(() => {})
1064
+ }
1065
+ return
1066
+ }
1067
+
1068
+ // Approve. Ack immediately + show an interim state, then persist in the
1069
+ // background (config_propose_edit can take minutes), then edit the card with
1070
+ // the real outcome. The turn resumes via the synthetic inbound injected by
1071
+ // resolveMentalModelProposal — not by this card edit.
1072
+ await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
1073
+ if (pending.card_message_id != null) {
1074
+ await ctx.api
1075
+ .editMessageText(
1076
+ pending.chat_id,
1077
+ pending.card_message_id,
1078
+ richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
1079
+ { reply_markup: { inline_keyboard: [] } },
1080
+ )
1081
+ .catch(() => {})
1082
+ }
1083
+ void (async () => {
1084
+ let result
1085
+ try {
1086
+ result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
1087
+ } catch (err) {
1088
+ process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
1089
+ result = { outcome: 'failed' as const, reason: (err as Error).message }
1090
+ }
1091
+ if (pending.card_message_id != null) {
1092
+ const label =
1093
+ result.outcome === 'applied'
1094
+ ? `✅ **Declared** ${escapeHtmlForTg(pending.agent)}'s mental model \`${pending.spec.name}\` — appended to \`memory.mental_models[]\` and ensured. Restart the agent to load it if it isn't picked up automatically.`
1095
+ : `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
1096
+ await ctx.api
1097
+ .editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
1098
+ reply_markup: { inline_keyboard: [] },
1099
+ link_preview_options: { is_disabled: true },
1100
+ })
1101
+ .catch(() => {})
1102
+ }
1103
+ })()
1104
+ }
1105
+
1106
+ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
1107
+ const senderId = String(ctx.from?.id ?? '')
1108
+ const access = loadAccess()
1109
+ if (!access.allowFrom.includes(senderId)) {
1110
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1111
+ return
1112
+ }
1113
+ const parts = data.split(':')
1114
+ if (parts.length < 3) {
1115
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
1116
+ return
1117
+ }
1118
+ const action = parts[1]
1119
+ const stageId = parts.slice(2).join(':')
1120
+ const pending = pendingVaultRequestAccesses.get(stageId)
1121
+ if (!pending) {
1122
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-request.' }).catch(() => {})
1123
+ if (ctx.callbackQuery?.message) {
1124
+ await ctx.api
1125
+ .editMessageText(
1126
+ ctx.callbackQuery.message.chat.id,
1127
+ ctx.callbackQuery.message.message_id,
1128
+ richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
1129
+ { reply_markup: { inline_keyboard: [] } },
1130
+ )
1131
+ .catch(() => {})
1132
+ }
1133
+ return
1134
+ }
1135
+
1136
+ if (action === 'deny') {
1137
+ pendingVaultRequestAccesses.delete(stageId)
1138
+ pendingCardStore.remove(stageId)
1139
+ await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
1140
+ if (pending.card_message_id != null) {
1141
+ await ctx.api
1142
+ .editMessageText(
1143
+ pending.chat_id,
1144
+ pending.card_message_id,
1145
+ richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
1146
+ { reply_markup: { inline_keyboard: [] } },
1147
+ )
1148
+ .catch(() => {})
1149
+ }
1150
+ // #1150 sibling: invariant-3 was missing on the deny path too. The
1151
+ // agent originally ended its turn after `vault_request_access` and
1152
+ // waits for the gateway to wake it. On approve we already inject
1153
+ // `vault_grant_approved` (#1052); now we mirror that for deny so
1154
+ // the agent can pick the fallback path (apologise to the user,
1155
+ // try a different approach, skip the feature) instead of staying
1156
+ // wedged forever. Buffer-on-failure so a mid-reconnect bridge
1157
+ // still receives this on its next register.
1158
+ const denyInbound = buildVaultGrantDeniedInbound({
1159
+ ctx: {
1160
+ agent: pending.agent,
1161
+ key: pending.key,
1162
+ scope: pending.scope,
1163
+ chat_id: pending.chat_id,
1164
+ ttl_seconds: pending.ttl_seconds,
1165
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1166
+ },
1167
+ stageId,
1168
+ operatorId: senderId,
1169
+ })
1170
+ const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
1171
+ process.stderr.write(
1172
+ `telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
1173
+ `key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
1174
+ )
1175
+ return
1176
+ }
1177
+
1178
+ if (action === 'approve') {
1179
+ // Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
1180
+ // higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
1181
+ // the grant must be minted with the operator passphrase — never
1182
+ // posture, even under telegram-id mode (the broker enforces the same
1183
+ // rule, so a posture mint would just be rejected). So for an
1184
+ // admin-only key we (a) reject taps from any non-admin allowFrom
1185
+ // member, and (b) skip the telegram-id posture branch below, falling
1186
+ // through to the passphrase-prompt path. The card + buttons stay
1187
+ // intact on a non-admin tap so the admin can still approve.
1188
+ const isAdminOnly = matchesAdminOnlyKey(pending.key, getAdminOnlyKeys())
1189
+ if (isAdminOnly && senderId !== access.allowFrom[0]) {
1190
+ await ctx
1191
+ .answerCallbackQuery({
1192
+ text: '🔒 Admin-only credential — only the owner can approve this.',
1193
+ })
1194
+ .catch(() => {})
1195
+ return
1196
+ }
1197
+
1198
+ // Posture: telegram-id (opt-in single-factor). The broker is
1199
+ // auto-unlocked and we silently hold the passphrase in memory; skip
1200
+ // the passphrase-cache lookup + prompt entirely and mint directly.
1201
+ // Allowlist check above already attested the operator's Telegram ID.
1202
+ // Admin-only keys are excluded — they take the passphrase path below.
1203
+ if (!isAdminOnly && getVaultApprovalAuthMode() === 'telegram-id') {
1204
+ const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
1205
+ if (pending.card_message_id != null) {
1206
+ await ctx.api
1207
+ .editMessageText(
1208
+ pending.chat_id,
1209
+ pending.card_message_id,
1210
+ richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
1211
+ { reply_markup: { inline_keyboard: [] } },
1212
+ )
1213
+ .catch(() => {})
1214
+ }
1215
+ await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
1216
+ await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'posture' })
1217
+ return
1218
+ }
1219
+
1220
+ // Tap-to-unlock-and-approve: if the operator hasn't unlocked the
1221
+ // vault in this chat yet, capture the passphrase via a pending op
1222
+ // intercept and resume the approve flow automatically once it
1223
+ // arrives — no second tap, no separate /vault unlock detour.
1224
+ // Mirrors the `passphrase-for-deferred` flow from #44.
1225
+ const cached = vaultPassphraseCache.get(pending.chat_id)
1226
+ if (!cached || cached.expiresAt <= Date.now()) {
1227
+ if (pending.card_message_id == null) {
1228
+ await ctx
1229
+ .answerCallbackQuery({ text: 'Card missing — ask the agent to re-issue.' })
1230
+ .catch(() => {})
1231
+ return
1232
+ }
1233
+ // #1051: if there's ALREADY a passphrase-for-access-approve
1234
+ // pending op for this chat (operator tapped Approve on a
1235
+ // sibling card before typing the passphrase), APPEND this
1236
+ // stage to the existing queue instead of overwriting. When
1237
+ // the passphrase reply lands the text-handler drains every
1238
+ // queued stage — both cards get their grant minted off one
1239
+ // passphrase entry. Without this, the second Approve tap
1240
+ // orphans the first stage.
1241
+ const existing = pendingVaultOps.get(pending.chat_id)
1242
+ const newItem = {
1243
+ stageId,
1244
+ cardChatId: pending.chat_id,
1245
+ cardMessageId: pending.card_message_id,
1246
+ senderId,
1247
+ }
1248
+ const items =
1249
+ existing?.kind === 'passphrase-for-access-approve'
1250
+ ? [...existing.items.filter((it) => it.stageId !== stageId), newItem]
1251
+ : [newItem]
1252
+ pendingVaultOps.set(pending.chat_id, {
1253
+ kind: 'passphrase-for-access-approve',
1254
+ items,
1255
+ startedAt: existing?.kind === 'passphrase-for-access-approve' ? existing.startedAt : Date.now(),
1256
+ })
1257
+ // Card text differs slightly when joining an existing batch so
1258
+ // the operator isn't confused by two "Reply with passphrase"
1259
+ // cards open at once.
1260
+ const joiningBatch = items.length > 1
1261
+ await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
1262
+
1263
+ // Strip the buttons on the ORIGINAL card and mark it "waiting" so it
1264
+ // can't be re-tapped, but do NOT overload it as the passphrase prompt.
1265
+ // An in-place edit fires no notification and stays pinned to the card's
1266
+ // old position in the chat, so a busy topic buries it and the operator
1267
+ // never sees the passphrase ask — the exact admin-key miss this fixes
1268
+ // (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
1269
+ // grant was never minted). The prompt goes out as a fresh message below.
1270
+ await ctx.api
1271
+ .editMessageText(
1272
+ pending.chat_id,
1273
+ pending.card_message_id,
1274
+ richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
1275
+ { reply_markup: { inline_keyboard: [] } },
1276
+ )
1277
+ .catch(() => {})
1278
+
1279
+ // The passphrase prompt as a NEW rich message. Three fixes vs. the old
1280
+ // in-place edit, all of which the reported bug needed:
1281
+ // 1. Real bold/italic — rendered through the sanctioned `richMessage`
1282
+ // GFM path (`sendRichMessage`), never a raw string. The old admin
1283
+ // and joining-batch branches passed raw markdown to editMessageText
1284
+ // (parse_mode=none), so `**`/`_` rendered as literal characters;
1285
+ // the "locked" branch even concatenated a string with a
1286
+ // `richMessage()` object (→ `[object Object]`). All three are gone.
1287
+ // 2. It lands at the BOTTOM of the chat, not stapled to an old card
1288
+ // that later messages bury.
1289
+ // 3. It fires a notification — `disable_notification` is deliberately
1290
+ // NOT set — so the operator is actually pinged to act.
1291
+ // Attention-grabbing header, short lines, key in code formatting.
1292
+ const promptText = joiningBatch
1293
+ ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1294
+ `Type your vault passphrase as your **next message**.\n` +
1295
+ `One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
1296
+ `_We delete the passphrase message the moment we read it._`
1297
+ : isAdminOnly
1298
+ ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1299
+ `\`${pending.key}\` is an **admin-only credential**.\n` +
1300
+ `Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
1301
+ `_The passphrase is what proves it's you. An agent can never mint this key on its own. We delete the passphrase message the moment we read it._`
1302
+ : `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1303
+ `Your vault is locked.\n` +
1304
+ `Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
1305
+ `_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches. We delete the passphrase message the moment we read it._`
1306
+
1307
+ // #1075: deleted-topic safe — fall back to the main chat. Wrapped
1308
+ // through robustApiCall for flood-wait retries, mirroring the card send.
1309
+ await retryWithThreadFallback<{ message_id: number }>(
1310
+ robustApiCall,
1311
+ (tid) =>
1312
+ lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
1313
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
1314
+ }),
1315
+ { threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
1316
+ ).catch(() => {})
1317
+ return
1318
+ }
1319
+
1320
+ await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
1321
+ await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'passphrase', passphrase: cached.passphrase })
1322
+ return
1323
+ }
1324
+
1325
+ await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
1326
+ }
1327
+
1328
+ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
1329
+ const senderId = String(ctx.from?.id ?? '')
1330
+ const access = loadAccess()
1331
+ if (!access.allowFrom.includes(senderId)) {
1332
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1333
+ return
1334
+ }
1335
+
1336
+ const parts = data.split(':')
1337
+ if (parts.length < 3) {
1338
+ await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
1339
+ return
1340
+ }
1341
+ const action = parts[1]
1342
+ const stageId = parts.slice(2).join(':')
1343
+ const pending = pendingVaultRequestSaves.get(stageId)
1344
+ if (!pending) {
1345
+ await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
1346
+ if (ctx.callbackQuery?.message) {
1347
+ await ctx.api
1348
+ .editMessageText(
1349
+ ctx.callbackQuery.message.chat.id,
1350
+ ctx.callbackQuery.message.message_id,
1351
+ richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
1352
+ { reply_markup: { inline_keyboard: [] } },
1353
+ )
1354
+ .catch(() => {})
1355
+ }
1356
+ return
1357
+ }
1358
+
1359
+ if (action === 'discard') {
1360
+ pendingVaultRequestSaves.delete(stageId)
1361
+ pendingCardStore.remove(stageId)
1362
+ await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
1363
+ if (pending.card_message_id != null) {
1364
+ await ctx.api
1365
+ .editMessageText(
1366
+ pending.chat_id,
1367
+ pending.card_message_id,
1368
+ richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
1369
+ { reply_markup: { inline_keyboard: [] } },
1370
+ )
1371
+ .catch(() => {})
1372
+ }
1373
+ // Wake the agent that called vault_request_save — symmetric with
1374
+ // the vra: approve/deny path (#1052/#1150/#1156). Without this the
1375
+ // tool returned "waiting for operator", the turn ended, and a
1376
+ // Discard left the agent silently idle forever.
1377
+ const discardInbound = buildVaultSaveDiscardedInbound({
1378
+ ctx: {
1379
+ agent: pending.agent,
1380
+ key: pending.key,
1381
+ chat_id: pending.chat_id,
1382
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1383
+ },
1384
+ stageId,
1385
+ operatorId: senderId,
1386
+ })
1387
+ const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
1388
+ process.stderr.write(
1389
+ `telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
1390
+ `key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
1391
+ )
1392
+ return
1393
+ }
1394
+
1395
+ if (action === 'rename') {
1396
+ // Set up a pending-op intercept so the user's next message is read
1397
+ // as the new key name. Same shape as the existing /vault set value
1398
+ // capture (gateway.ts uses pendingVaultOps for this).
1399
+ pendingVaultOps.set(pending.chat_id, {
1400
+ kind: 'rename-vault-save',
1401
+ stageId,
1402
+ startedAt: Date.now(),
1403
+ } as PendingVaultOp)
1404
+ // #1150 audit P0: pre-fix the [Save once][Discard][Rename] keyboard
1405
+ // stayed live after the rename tap so the operator could re-tap
1406
+ // Save with the old key name mid-rename — a Save tap fires the
1407
+ // write immediately, racing the rename intercept. Strip the
1408
+ // keyboard atomically with a status line that names the rename
1409
+ // mode + the proposed new-key prompt. No synthInbound — the
1410
+ // agent's `vault_request_save` tool already returned "waiting
1411
+ // for operator," and the eventual save success/failure flows
1412
+ // its own wake-up below.
1413
+ const sourceMsg = ctx.callbackQuery?.message
1414
+ const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
1415
+ ? escapeHtmlForTg(sourceMsg.text)
1416
+ : ''
1417
+ const statusLine =
1418
+ `\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
1419
+ `The current proposed key is \`${pending.key}\`.`
1420
+ await finalizeCallback(ctx, {
1421
+ ackText: 'Send the new key name as your next message.',
1422
+ newText: baseText ? `${baseText}${statusLine}` : statusLine,
1423
+ })
1424
+ return
1425
+ }
1426
+
1427
+ if (action === 'save') {
1428
+ // Acknowledge the tap immediately so Telegram doesn't show a
1429
+ // stale "spinning" state on the button while we run the write.
1430
+ await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
1431
+
1432
+ // Restored-after-restart guard: the staged secret VALUE is held in gateway
1433
+ // memory only and is never persisted (secrets hygiene). If this card was
1434
+ // restored from disk after a gateway restart, the value is gone — we CANNOT
1435
+ // complete the write. Degrade gracefully: strip the card, wake the agent
1436
+ // with a save-failed (value-lost) synthetic so it re-requests, and stop.
1437
+ if (pending.restoredWithoutValue || pending.value.length === 0) {
1438
+ pendingVaultRequestSaves.delete(stageId)
1439
+ pendingCardStore.remove(stageId)
1440
+ if (pending.card_message_id != null) {
1441
+ await ctx.api
1442
+ .editMessageText(
1443
+ pending.chat_id,
1444
+ pending.card_message_id,
1445
+ richMessage(`⚠️ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart — nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
1446
+ { reply_markup: { inline_keyboard: [] } },
1447
+ )
1448
+ .catch(() => {})
1449
+ }
1450
+ const lostInbound = buildVaultSaveFailedInbound({
1451
+ ctx: {
1452
+ agent: pending.agent,
1453
+ key: pending.key,
1454
+ chat_id: pending.chat_id,
1455
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1456
+ },
1457
+ stageId,
1458
+ operatorId: senderId,
1459
+ reason: 'staged value lost to a gateway restart — re-request the save',
1460
+ })
1461
+ const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
1462
+ process.stderr.write(
1463
+ `telegram gateway: vault_request_save value lost to restart — wake agent=${pending.agent} ` +
1464
+ `key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
1465
+ )
1466
+ return
1467
+ }
1468
+
1469
+ // #1115 follow-up: the save-approve flow now mirrors the access-
1470
+ // approve flow under telegram-id mode — broker `put` accepts
1471
+ // `attest_via_posture: true` (server.ts:1448-1500), so the
1472
+ // gateway can attest the write without a cached passphrase.
1473
+ // Closes the UX gap where tapping Save surfaced a misleading
1474
+ // "🔒 Vault is locked" message even when the broker had been
1475
+ // auto-unlocked at boot.
1476
+ //
1477
+ // Branch: under telegram-id mode use the posture-attested put;
1478
+ // under passphrase mode keep the existing cached-passphrase +
1479
+ // shell-to-CLI path (operator must `/vault unlock` once per
1480
+ // chat session to populate `vaultPassphraseCache`).
1481
+ let write: { ok: boolean; output: string }
1482
+ if (getVaultApprovalAuthMode() === 'telegram-id') {
1483
+ // Posture-attested broker put. No passphrase needed. The broker
1484
+ // verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
1485
+ // unlocked — see server.ts:1448-1500.
1486
+ write = await defaultVaultWritePosture(pending.key, pending.value)
1487
+ } else {
1488
+ // Passphrase mode — fetch the cached passphrase for this chat.
1489
+ // If the gateway hasn't seen the user unlock the vault yet, we
1490
+ // can't attest the write — surface the unlock prompt.
1491
+ const cached = vaultPassphraseCache.get(pending.chat_id)
1492
+ if (!cached || cached.expiresAt <= Date.now()) {
1493
+ if (pending.card_message_id != null) {
1494
+ await ctx.api
1495
+ .editMessageText(
1496
+ pending.chat_id,
1497
+ pending.card_message_id,
1498
+ richMessage(`🔒 **Passphrase not cached for this chat.** Run \`/vault unlock\` (or any /vault command) to cache it, then tap Save again on the next card.`),
1499
+ { reply_markup: { inline_keyboard: [] } },
1500
+ )
1501
+ .catch(() => {})
1502
+ }
1503
+ pendingVaultRequestSaves.delete(stageId)
1504
+ pendingCardStore.remove(stageId)
1505
+ return
1506
+ }
1507
+ // defaultVaultWrite spawns `switchroom vault set <key>` with the
1508
+ // passphrase env set; the CLI forwards the passphrase to the
1509
+ // broker put as operator-attestation (#969 P1a), which authorizes
1510
+ // new-key creation.
1511
+ write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
1512
+ }
1513
+
1514
+ if (!write.ok) {
1515
+ // Route through the structured-error renderer from #969 P0b so
1516
+ // failures show the actionable host hint instead of a raw blob.
1517
+ const parsed = parseVaultCliError(write.output)
1518
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
1519
+ const body = rendered.suppressRaw
1520
+ ? rendered.html
1521
+ : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
1522
+ if (pending.card_message_id != null) {
1523
+ await ctx.api
1524
+ .editMessageText(
1525
+ pending.chat_id,
1526
+ pending.card_message_id,
1527
+ richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
1528
+ { reply_markup: { inline_keyboard: [] } },
1529
+ )
1530
+ .catch(() => {})
1531
+ }
1532
+ // Leave the staged secret in memory until TTL — operator might
1533
+ // retry by re-invoking the same MCP tool, but the value will be
1534
+ // re-staged with a new ID. Drop the current stage.
1535
+ pendingVaultRequestSaves.delete(stageId)
1536
+ pendingCardStore.remove(stageId)
1537
+ // Wake the waiting agent with the failure (symmetric with the
1538
+ // success/discard paths) so it doesn't assume vault:<key> exists.
1539
+ const failReason =
1540
+ (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
1541
+ const failInbound = buildVaultSaveFailedInbound({
1542
+ ctx: {
1543
+ agent: pending.agent,
1544
+ key: pending.key,
1545
+ chat_id: pending.chat_id,
1546
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1547
+ },
1548
+ stageId,
1549
+ operatorId: senderId,
1550
+ reason: failReason,
1551
+ })
1552
+ const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
1553
+ process.stderr.write(
1554
+ `telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
1555
+ `key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
1556
+ )
1557
+ return
1558
+ }
1559
+
1560
+ // Success — mask the value in the card for visual confirmation.
1561
+ pendingVaultRequestSaves.delete(stageId)
1562
+ pendingCardStore.remove(stageId)
1563
+ if (pending.card_message_id != null) {
1564
+ await ctx.api
1565
+ .editMessageText(
1566
+ pending.chat_id,
1567
+ pending.card_message_id,
1568
+ richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
1569
+ { reply_markup: { inline_keyboard: [] } },
1570
+ )
1571
+ .catch(() => {})
1572
+ }
1573
+ // Wake the agent that called vault_request_save so it resumes the
1574
+ // task that was blocked on this credential (symmetric with the
1575
+ // vra: approve path; buffered if the bridge is mid-reconnect).
1576
+ const okInbound = buildVaultSaveCompletedInbound({
1577
+ ctx: {
1578
+ agent: pending.agent,
1579
+ key: pending.key,
1580
+ chat_id: pending.chat_id,
1581
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1582
+ },
1583
+ stageId,
1584
+ operatorId: senderId,
1585
+ })
1586
+ const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
1587
+ process.stderr.write(
1588
+ `telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
1589
+ `key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
1590
+ )
1591
+ return
1592
+ }
1593
+
1594
+ await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
1595
+ }
1596
+
1597
+ async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
1598
+ const senderId = String(ctx.from?.id ?? '')
1599
+ const access = loadAccess()
1600
+ if (!access.allowFrom.includes(senderId)) {
1601
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1602
+ return
1603
+ }
1604
+ // vd:<action>:<deferKey>. deferKey itself contains a colon (chat:msgId)
1605
+ // so we slice rather than split — only the first two segments are
1606
+ // structural; the rest is the deferKey verbatim.
1607
+ const rest = data.slice('vd:'.length)
1608
+ const colon = rest.indexOf(':')
1609
+ if (colon < 0) {
1610
+ await ctx.answerCallbackQuery({ text: 'Malformed callback.' }).catch(() => {})
1611
+ return
1612
+ }
1613
+ const action = rest.slice(0, colon)
1614
+ const deferKey = rest.slice(colon + 1)
1615
+ const deferred = deferredSecrets.get(deferKey)
1616
+ if (!deferred) {
1617
+ await ctx.answerCallbackQuery({ text: 'This card expired. Re-send the secret.' }).catch(() => {})
1618
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1619
+ return
1620
+ }
1621
+
1622
+ const cardChatId = String(ctx.chat?.id ?? '')
1623
+ const cardMessageId = ctx.callbackQuery?.message?.message_id
1624
+
1625
+ if (action === 'cancel') {
1626
+ // Kernel-side dual-dispatch (MIGRATION.md §1): record the deny decision
1627
+ // BEFORE the legacy handler clears state, so the audit log captures it
1628
+ // even if the editMessageText below races with another tap. Best-effort
1629
+ // — broker unreachable falls back to legacy-only.
1630
+ await recordDeferredSecretKernelDecision(
1631
+ deferred.kernel_request_id,
1632
+ 'deny',
1633
+ ctx.from?.id ?? 0,
1634
+ access.allowFrom,
1635
+ )
1636
+ deferredSecrets.delete(deferKey)
1637
+ await ctx.answerCallbackQuery({ text: 'Discarded.' }).catch(() => {})
1638
+ if (cardMessageId != null) {
1639
+ await ctx
1640
+ .editMessageText('🗑 Discarded — secret was not saved.', {
1641
+ reply_markup: { inline_keyboard: [] },
1642
+ })
1643
+ .catch(() => {})
1644
+ }
1645
+ return
1646
+ }
1647
+
1648
+ if (action === 'unlock') {
1649
+ // Kernel-side dual-dispatch (MIGRATION.md §1): record the allow_once
1650
+ // decision when the user taps unlock. The actual passphrase capture +
1651
+ // vault write still happens via the legacy path below — the kernel
1652
+ // decision is for audit/state, not secret material (per RFC B). We
1653
+ // record at tap-time rather than after passphrase entry so a kernel
1654
+ // record exists even if the user abandons the passphrase prompt.
1655
+ await recordDeferredSecretKernelDecision(
1656
+ deferred.kernel_request_id,
1657
+ 'allow_once',
1658
+ ctx.from?.id ?? 0,
1659
+ access.allowFrom,
1660
+ )
1661
+ // #1115 follow-up: telegram-id mode silent-defer-save was withdrawn
1662
+ // (same reason as the save-callback above — the in-memory
1663
+ // passphrase short-circuit became a bypass surface). The
1664
+ // deferred-secret save falls through to the cached-passphrase
1665
+ // path under all postures. Routing executeDeferredSecretSave
1666
+ // through broker-IPC attest_via_posture is a tracked follow-up.
1667
+
1668
+ // If a passphrase is already cached we can skip straight to the write.
1669
+ // Covers the case where the user had unlocked separately between
1670
+ // detection and tap.
1671
+ const cached = vaultPassphraseCache.get(cardChatId)
1672
+ if (cached && cached.expiresAt > Date.now()) {
1673
+ await ctx.answerCallbackQuery({ text: 'Saving…' }).catch(() => {})
1674
+ await executeDeferredSecretSave(ctx, deferKey, cached.passphrase, cardMessageId)
1675
+ return
1676
+ }
1677
+
1678
+ if (cardMessageId == null) {
1679
+ await ctx.answerCallbackQuery({ text: 'Missing card context.' }).catch(() => {})
1680
+ return
1681
+ }
1682
+ pendingVaultOps.set(cardChatId, {
1683
+ kind: 'passphrase-for-deferred',
1684
+ deferKey,
1685
+ cardChatId,
1686
+ cardMessageId,
1687
+ startedAt: Date.now(),
1688
+ })
1689
+ await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
1690
+ await ctx
1691
+ .editMessageText(
1692
+ richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
1693
+ { reply_markup: { inline_keyboard: [] } },
1694
+ )
1695
+ .catch(() => {})
1696
+ return
1697
+ }
1698
+
1699
+ await ctx.answerCallbackQuery({ text: 'Unknown action.' }).catch(() => {})
1700
+ }
1701
+
1702
+ // ─── Grant wizard helpers (Issue #227) ──────────────────────────────────────
1703
+ // TODO: these helpers duplicate server.ts — extract to a shared module in a
1704
+ // future refactor once the two entrypoints are proven stable in production.
1705
+
1706
+ /** Parse a duration string like "30d", "7h", "365d" into seconds. */
1707
+ function parseGrantDuration(s: string): number | null {
1708
+ const m = /^(\d+)([dh])$/i.exec(s.trim())
1709
+ if (!m) return null
1710
+ const n = parseInt(m[1]!, 10)
1711
+ if (n <= 0) return null
1712
+ return m[2]!.toLowerCase() === 'd' ? n * 86400 : n * 3600
1713
+ }
1714
+
1715
+ /** Format seconds as a human-readable expiry label. */
1716
+ function formatGrantExpiry(ttlSeconds: number | null, now: Date = new Date()): string {
1717
+ if (ttlSeconds === null) return 'Never'
1718
+ const exp = new Date(now.getTime() + ttlSeconds * 1000)
1719
+ return exp.toISOString().slice(0, 10)
1720
+ }
1721
+
1722
+ /** Build the Step 1 keyboard: agent selection. */
1723
+ function buildGrantAgentKeyboard(agents: string[]): InlineKeyboard {
1724
+ const kb = new InlineKeyboard()
1725
+ // Max 3 per row to keep buttons readable on mobile
1726
+ for (let i = 0; i < agents.length; i++) {
1727
+ if (i > 0 && i % 3 === 0) kb.row()
1728
+ kb.text(agents[i]!, `vg:agent:${agents[i]!}`)
1729
+ }
1730
+ kb.row().text('Cancel', 'vg:cancel')
1731
+ return kb
1732
+ }
1733
+
1734
+ /** Build the Step 2 keyboard: key multi-select toggle. */
1735
+ function buildGrantKeysKeyboard(keys: string[], selected: Set<string>): InlineKeyboard {
1736
+ const kb = new InlineKeyboard()
1737
+ for (const k of keys) {
1738
+ const check = selected.has(k) ? '☑' : '☐'
1739
+ kb.row().text(`${check} ${k}`, `vg:key:${k}`)
1740
+ }
1741
+ kb.row()
1742
+ .text('Continue', 'vg:keys-continue')
1743
+ .text('Cancel', 'vg:cancel')
1744
+ return kb
1745
+ }
1746
+
1747
+ /** Build the Step 3 keyboard: duration selection. */
1748
+ function buildGrantDurationKeyboard(): InlineKeyboard {
1749
+ return new InlineKeyboard()
1750
+ .text('30 days', 'vg:dur:30d')
1751
+ .text('90 days', 'vg:dur:90d')
1752
+ .text('1 year', 'vg:dur:1y')
1753
+ .row()
1754
+ .text('Custom…', 'vg:dur:custom')
1755
+ .text('No expiry', 'vg:dur:never')
1756
+ .row()
1757
+ .text('Back', 'vg:back:duration')
1758
+ .text('Cancel', 'vg:cancel')
1759
+ }
1760
+
1761
+ /** Build the Confirm keyboard. */
1762
+ function buildGrantConfirmKeyboard(): InlineKeyboard {
1763
+ return new InlineKeyboard()
1764
+ .text('Generate', 'vg:generate')
1765
+ .text('Cancel', 'vg:cancel')
1766
+ }
1767
+
1768
+ /** Start the grant wizard (step 1: pick agent). */
1769
+ async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void> {
1770
+ type AgentListResp = { agents: Array<{ name: string }> }
1771
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
1772
+ const agents = data?.agents?.map(a => a.name).filter(Boolean) ?? []
1773
+ if (agents.length === 0) {
1774
+ await switchroomReply(ctx, '⚠️ No agents found in switchroom.yaml.', { html: true })
1775
+ return
1776
+ }
1777
+ const kb = buildGrantAgentKeyboard(agents)
1778
+ const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
1779
+ const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
1780
+ pendingVaultOps.set(chatId, {
1781
+ kind: 'grant-wizard',
1782
+ step: 'agent',
1783
+ wizardMsgId,
1784
+ startedAt: Date.now(),
1785
+ })
1786
+ }
1787
+
1788
+ /** Advance grant wizard to step 2 (pick keys). */
1789
+ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wizardMsgId: number | undefined): Promise<void> {
1790
+ const keys = await listViaBroker()
1791
+ if (!keys) {
1792
+ await switchroomReply(ctx, '🔴 Broker is not running (or unreachable). Cannot list vault keys.', { html: true })
1793
+ pendingVaultOps.delete(chatId)
1794
+ return
1795
+ }
1796
+ if (keys.length === 0) {
1797
+ await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
1798
+ pendingVaultOps.delete(chatId)
1799
+ return
1800
+ }
1801
+ const selected = new Set<string>()
1802
+ const kb = buildGrantKeysKeyboard(keys, selected)
1803
+ const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
1804
+ if (wizardMsgId != null) {
1805
+ // allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
1806
+ await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
1807
+ } else {
1808
+ const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
1809
+ wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
1810
+ }
1811
+ pendingVaultOps.set(chatId, {
1812
+ kind: 'grant-wizard',
1813
+ step: 'keys',
1814
+ agent,
1815
+ selectedKeys: [],
1816
+ availableKeys: keys,
1817
+ wizardMsgId,
1818
+ startedAt: Date.now(),
1819
+ })
1820
+ }
1821
+
1822
+ /** Advance grant wizard to step 3 (pick duration). */
1823
+ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
1824
+ const kb = buildGrantDurationKeyboard()
1825
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
1826
+ const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
1827
+ const msgId = state.wizardMsgId
1828
+ if (msgId != null) {
1829
+ // allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
1830
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
1831
+ } else {
1832
+ const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
1833
+ state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
1834
+ }
1835
+ pendingVaultOps.set(chatId, { ...state, step: 'duration' })
1836
+ }
1837
+
1838
+ /** Advance grant wizard to confirmation step. */
1839
+ async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
1840
+ const kb = buildGrantConfirmKeyboard()
1841
+ const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
1842
+ const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
1843
+ const text = [
1844
+ '**Confirm grant**',
1845
+ '',
1846
+ `Agent: \`${state.agent!}\``,
1847
+ `Keys:\n${keyList}`,
1848
+ `Expires: **${escapeHtmlForTg(expiresLabel)}**`,
1849
+ '',
1850
+ 'Tap **Generate** to mint the token.',
1851
+ ].join('\n')
1852
+ const msgId = state.wizardMsgId
1853
+ if (msgId != null) {
1854
+ // allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
1855
+ await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
1856
+ } else {
1857
+ const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
1858
+ state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
1859
+ }
1860
+ // Mint kernel decision row at the confirm step (MIGRATION.md §2,
1861
+ // audit-only Phase 1). We do it here rather than at executeGrantWizard
1862
+ // so a kernel row exists even if the user taps Cancel from the confirm
1863
+ // card — the deny verdict on cancel is then recorded against the same
1864
+ // request_id. If the kernel/broker is unreachable, request_id stays
1865
+ // undefined and the wizard runs legacy-only (no behaviour change).
1866
+ const kernelRequestId = await mintGrantWizardKernelRequest(
1867
+ state.agent!,
1868
+ loadAccess().allowFrom,
1869
+ state.selectedKeys!,
1870
+ state.ttlSeconds ?? null,
1871
+ )
1872
+ pendingVaultOps.set(chatId, {
1873
+ ...state,
1874
+ step: 'confirm',
1875
+ expiresLabel,
1876
+ kernel_request_id: kernelRequestId ?? state.kernel_request_id,
1877
+ })
1878
+ }
1879
+
1880
+ /** Execute the grant: call broker mint_grant, write token, reply. */
1881
+ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
1882
+ pendingVaultOps.delete(chatId)
1883
+ // Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
1884
+ // record the allow_once decision when the user taps Generate. The
1885
+ // legacy `mintGrantViaBroker` below still drives the actual grant
1886
+ // mint + token write — the kernel row is informational, not
1887
+ // enforcing, in Phase 1 (issue #833 will flip to enforcing).
1888
+ // We record at tap-time rather than after mint_grant succeeds so a
1889
+ // kernel row exists even if the legacy mint fails (audit captures
1890
+ // intent regardless of downstream outcome).
1891
+ await recordGrantWizardKernelDecision(
1892
+ state.kernel_request_id,
1893
+ 'allow_once',
1894
+ ctx.from?.id ?? 0,
1895
+ loadAccess().allowFrom,
1896
+ )
1897
+ // Defence-in-depth: state.agent flows from callback_data into a path
1898
+ // join below. A crafted vg:agent:../../etc payload would produce a
1899
+ // path traversal. Validate against the same regex the rest of the
1900
+ // file uses; on failure, drop silently — the wizard message has
1901
+ // already been finalized.
1902
+ try { assertSafeAgentName(state.agent!) } catch { return }
1903
+ const result = await mintGrantViaBroker({
1904
+ agent: state.agent!,
1905
+ keys: state.selectedKeys!,
1906
+ ttl_seconds: state.ttlSeconds ?? null,
1907
+ description: state.description,
1908
+ })
1909
+ if (result.kind === 'unreachable') {
1910
+ await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
1911
+ return
1912
+ }
1913
+ if (result.kind === 'error') {
1914
+ await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
1915
+ return
1916
+ }
1917
+ // Write token to the agent's .vault-token file
1918
+ const { token, id } = result
1919
+ const tokenPath = join(homedir(), '.switchroom', 'agents', state.agent!, '.vault-token')
1920
+ try {
1921
+ mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
1922
+ writeFileSync(tokenPath, token, { mode: 0o600 })
1923
+ } catch (err) {
1924
+ await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
1925
+ return
1926
+ }
1927
+ // Collapse wizard message to just the outcome.
1928
+ // #1150 audit: P0 fix — pre-fix this `editMessageText` call omitted
1929
+ // `reply_markup: { inline_keyboard: [] }` so the wizard's [Generate]
1930
+ // / [Cancel] buttons stayed tappable on the success card. Operator
1931
+ // could re-tap [Generate] and mint a second redundant grant.
1932
+ // Strip the keyboard atomically with the success text via the
1933
+ // finalizeCallback helper.
1934
+ const msgId = state.wizardMsgId
1935
+ const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
1936
+ if (msgId != null) {
1937
+ await finalizeCallback(ctx, {
1938
+ ackText: '✅ Grant created',
1939
+ newText: successText,
1940
+ // No synthInbound — operator-only flow.
1941
+ })
1942
+ } else {
1943
+ // Fallback when wizard message id was lost (rare; e.g. operator
1944
+ // deleted the card). Send a fresh reply with the success text;
1945
+ // no keyboard to strip in this branch.
1946
+ await switchroomReply(ctx, successText, { html: true })
1947
+ }
1948
+ }
1949
+
1950
+ /**
1951
+ * Issue #228: handle vault grant management callbacks.
1952
+ *
1953
+ * `vg:revoke:<grantId>` — fetch grant details and show confirmation card.
1954
+ * `vg:confirm:<grantId>` — call broker revoke_grant, reply with success.
1955
+ * `vg:cancel:<grantId>` — dismiss (clear keyboard, no broker call).
1956
+ *
1957
+ * Issue #227: also handles /vault grant wizard callbacks.
1958
+ *
1959
+ * `vg:cancel` — cancel wizard at any step.
1960
+ * `vg:agent:<name>` — step 1: select agent.
1961
+ * `vg:key:<name>` — step 2: toggle key selection.
1962
+ * `vg:keys-continue` — step 2 → 3.
1963
+ * `vg:dur:<value>` — step 3: duration selection.
1964
+ * `vg:back:duration` — step 3 → back to step 2.
1965
+ * `vg:generate` — confirm and mint token.
1966
+ */
1967
+ async function handleVaultGrantCallback(ctx: Context, data: string): Promise<void> {
1968
+ const senderId = String(ctx.from?.id ?? '')
1969
+ const access = loadAccess()
1970
+ if (!access.allowFrom.includes(senderId)) {
1971
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1972
+ return
1973
+ }
1974
+
1975
+ const revokeMatch = /^vg:revoke:(.+)$/.exec(data)
1976
+ if (revokeMatch) {
1977
+ const grantId = revokeMatch[1]!
1978
+ const result = await listGrantsViaBroker(undefined)
1979
+ if (result.kind !== 'ok') {
1980
+ await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
1981
+ return
1982
+ }
1983
+ const grant = result.grants.find(g => g.id === grantId)
1984
+ if (!grant) {
1985
+ await ctx.answerCallbackQuery({ text: 'Grant not found (already revoked?).' }).catch(() => {})
1986
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
1987
+ return
1988
+ }
1989
+ const cardText =
1990
+ `🗑 Revoke \`${grantId}\`?\n` +
1991
+ `Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
1992
+ `Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
1993
+ const confirmKeyboard = new InlineKeyboard()
1994
+ .text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
1995
+ .text('❌ Cancel', `vg:cancel:${grantId}`)
1996
+ await ctx.answerCallbackQuery().catch(() => {})
1997
+ await ctx.editMessageText(richMessage(cardText), {
1998
+ reply_markup: confirmKeyboard,
1999
+ }).catch(async () => {
2000
+ const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
2001
+ const threadId = ctx.callbackQuery?.message?.message_thread_id
2002
+ if (chatId) {
2003
+ // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
2004
+ await swallowingApiCall(
2005
+ () =>
2006
+ bot.api.sendRichMessage(chatId, richMessage(cardText), {
2007
+ reply_markup: confirmKeyboard,
2008
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
2009
+ }),
2010
+ {
2011
+ chat_id: chatId,
2012
+ verb: 'vault-revoke-confirm-fallback',
2013
+ ...(threadId != null ? { threadId } : {}),
2014
+ },
2015
+ )
2016
+ }
2017
+ })
2018
+ return
2019
+ }
2020
+
2021
+ const confirmMatch = /^vg:confirm:(.+)$/.exec(data)
2022
+ if (confirmMatch) {
2023
+ const grantId = confirmMatch[1]!
2024
+ const revokeResult = await revokeGrantViaBroker(grantId)
2025
+ if (revokeResult.kind === 'unreachable') {
2026
+ await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
2027
+ return
2028
+ }
2029
+ if (revokeResult.kind === 'error') {
2030
+ await ctx.answerCallbackQuery({ text: `Revoke failed: ${revokeResult.msg}` }).catch(() => {})
2031
+ return
2032
+ }
2033
+ await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
2034
+ await ctx.editMessageText(
2035
+ richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
2036
+ { reply_markup: { inline_keyboard: [] } },
2037
+ ).catch(() => {})
2038
+ return
2039
+ }
2040
+
2041
+ const cancelMatch = /^vg:cancel:(.+)$/.exec(data)
2042
+ if (cancelMatch) {
2043
+ await ctx.answerCallbackQuery({ text: 'Cancelled.' }).catch(() => {})
2044
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
2045
+ return
2046
+ }
2047
+
2048
+ // #227 grant wizard callbacks (vg:cancel bare, vg:agent:*, vg:key:*, vg:keys-continue,
2049
+ // vg:dur:*, vg:back:*, vg:generate). These come after the management callbacks above
2050
+ // because management uses vg:cancel:<id> (with trailing id) while the wizard uses
2051
+ // bare vg:cancel — the cancelMatch above only matches the id-suffixed form.
2052
+ //
2053
+ // Note: pre-#265 fix this function did `await ctx.answerCallbackQuery().catch(() => {})`
2054
+ // unconditionally up front. That meant the `vg:keys-continue` branch's
2055
+ // toast call (`Select at least one key.`) hit a Telegram error
2056
+ // ("query is too old or query ID is invalid") because the query was
2057
+ // already answered, and the toast never reached the user. Each branch
2058
+ // now owns its own ack.
2059
+ const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
2060
+ const ackSilently = () => ctx.answerCallbackQuery().catch(() => {})
2061
+
2062
+ // Cancel at any wizard step
2063
+ if (data === 'vg:cancel') {
2064
+ // Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
2065
+ // if the user got as far as the confirm step, a kernel request_id
2066
+ // will be on the wizard state — record the deny decision so the
2067
+ // audit log captures the abandonment. No-op if the user cancelled
2068
+ // before the confirm step (or if the kernel was unreachable).
2069
+ const cancelState = pendingVaultOps.get(chatId)
2070
+ if (cancelState && cancelState.kind === 'grant-wizard') {
2071
+ await recordGrantWizardKernelDecision(
2072
+ cancelState.kernel_request_id,
2073
+ 'deny',
2074
+ ctx.from?.id ?? 0,
2075
+ loadAccess().allowFrom,
2076
+ )
2077
+ }
2078
+ pendingVaultOps.delete(chatId)
2079
+ const msg = ctx.callbackQuery?.message
2080
+ if (msg && 'text' in msg) {
2081
+ await ctx.editMessageText('❌ Grant wizard cancelled.').catch(() => {})
2082
+ }
2083
+ await ackSilently()
2084
+ return
2085
+ }
2086
+
2087
+ const state = pendingVaultOps.get(chatId)
2088
+ if (!state || state.kind !== 'grant-wizard') {
2089
+ await ctx.editMessageText('⚠️ Wizard session expired. Run /vault grant to start again.').catch(() => {})
2090
+ await ackSilently()
2091
+ return
2092
+ }
2093
+
2094
+ // vg:agent:<name> — step 1 selection
2095
+ if (data.startsWith('vg:agent:')) {
2096
+ const agent = data.slice('vg:agent:'.length)
2097
+ const msgId = (ctx.callbackQuery?.message as { message_id?: number })?.message_id ?? state.wizardMsgId
2098
+ await grantWizardStep2(ctx, chatId, agent, msgId)
2099
+ await ackSilently()
2100
+ return
2101
+ }
2102
+
2103
+ // vg:key:<name> — step 2 toggle
2104
+ if (data.startsWith('vg:key:')) {
2105
+ const key = data.slice('vg:key:'.length)
2106
+ if (state.step !== 'keys') { await ackSilently(); return }
2107
+ const selectedSet = new Set(state.selectedKeys ?? [])
2108
+ if (selectedSet.has(key)) {
2109
+ selectedSet.delete(key)
2110
+ } else {
2111
+ selectedSet.add(key)
2112
+ }
2113
+ const updatedState = { ...state, selectedKeys: [...selectedSet] }
2114
+ pendingVaultOps.set(chatId, updatedState)
2115
+ const kb = buildGrantKeysKeyboard(state.availableKeys ?? [], selectedSet)
2116
+ await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {})
2117
+ await ackSilently()
2118
+ return
2119
+ }
2120
+
2121
+ // vg:keys-continue — step 2 → 3
2122
+ if (data === 'vg:keys-continue') {
2123
+ if (state.step !== 'keys') { await ackSilently(); return }
2124
+ if (!state.selectedKeys || state.selectedKeys.length === 0) {
2125
+ // Toast-only ack: this is the branch the unconditional pre-ack
2126
+ // used to silently swallow. See #265.
2127
+ await ctx.answerCallbackQuery({ text: 'Select at least one key.' }).catch(() => {})
2128
+ return
2129
+ }
2130
+ await grantWizardStep3(ctx, chatId, state)
2131
+ await ackSilently()
2132
+ return
2133
+ }
2134
+
2135
+ // vg:dur:<value> — step 3 duration selection
2136
+ if (data.startsWith('vg:dur:')) {
2137
+ if (state.step !== 'duration') { await ackSilently(); return }
2138
+ const dur = data.slice('vg:dur:'.length)
2139
+ if (dur === 'custom') {
2140
+ // Ask for text reply with n d|h format
2141
+ pendingVaultOps.set(chatId, { ...state, awaitingCustomDuration: true })
2142
+ const msg = ctx.callbackQuery?.message
2143
+ if (msg && 'text' in msg && msg.text) {
2144
+ // Escape source text before re-rendering with HTML parse mode.
2145
+ // `msg.text` returns entities-stripped plain UTF-8; a raw
2146
+ // `<`/`>`/`&` in the wizard's prior-step body (e.g. a future
2147
+ // key or label) would crash the HTML re-parse and the bare
2148
+ // `.catch(() => {})` would swallow the failure silently — same
2149
+ // hazard PR #1158 caught on the operator-event card.
2150
+ await ctx.editMessageText(
2151
+ richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
2152
+ { reply_markup: buildGrantDurationKeyboard() },
2153
+ ).catch(() => {})
2154
+ }
2155
+ await ackSilently()
2156
+ return
2157
+ }
2158
+ let ttlSeconds: number | null
2159
+ if (dur === 'never') {
2160
+ ttlSeconds = null
2161
+ } else if (dur === '1y') {
2162
+ ttlSeconds = 365 * 86400
2163
+ } else {
2164
+ ttlSeconds = parseGrantDuration(dur)
2165
+ if (ttlSeconds === null) { await ackSilently(); return }
2166
+ }
2167
+ const newState = { ...state, ttlSeconds, awaitingCustomDuration: false }
2168
+ await grantWizardConfirm(ctx, chatId, newState)
2169
+ await ackSilently()
2170
+ return
2171
+ }
2172
+
2173
+ // vg:back:duration — go back to step 2 (keys selection) from step 3
2174
+ if (data === 'vg:back:duration') {
2175
+ if (state.step !== 'duration') { await ackSilently(); return }
2176
+ const msgId = state.wizardMsgId
2177
+ await grantWizardStep2(ctx, chatId, state.agent!, msgId)
2178
+ await ackSilently()
2179
+ return
2180
+ }
2181
+
2182
+ // vg:generate — final step
2183
+ if (data === 'vg:generate') {
2184
+ if (state.step !== 'confirm') { await ackSilently(); return }
2185
+ await executeGrantWizard(ctx, chatId, state)
2186
+ await ackSilently()
2187
+ return
2188
+ }
2189
+
2190
+ // Unrecognised vg: sub-action
2191
+ await ackSilently()
2192
+ }
2193
+
2194
+ /**
2195
+ * Issue #44: write a deferred secret to the vault using the now-cached
2196
+ * passphrase. Confirms with a masked ref + slug; matches the "captured
2197
+ * N secret" UX of the cached-passphrase happy path so the user
2198
+ * experience is identical regardless of which path they came in on.
2199
+ *
2200
+ * Called from two places:
2201
+ * - The `passphrase-for-deferred` branch of the text-handler
2202
+ * pendingVaultOps intercept, after the passphrase is verified.
2203
+ * - The `vd:unlock` callback handler when a passphrase happens to
2204
+ * already be cached (rare but possible).
2205
+ *
2206
+ * If write fails, the deferred entry is preserved so the user can retry.
2207
+ */
2208
+ async function executeDeferredSecretSave(
2209
+ ctx: Context,
2210
+ deferKey: string,
2211
+ passphrase: string,
2212
+ cardMessageId: number | undefined,
2213
+ ): Promise<void> {
2214
+ const deferred = deferredSecrets.get(deferKey)
2215
+ if (!deferred) {
2216
+ if (cardMessageId != null) {
2217
+ await ctx.api
2218
+ .editMessageText(
2219
+ deferKey.split(':')[0]!,
2220
+ cardMessageId,
2221
+ '⚠️ This card expired before unlock — please re-send the secret.',
2222
+ { reply_markup: { inline_keyboard: [] } },
2223
+ )
2224
+ .catch(() => {})
2225
+ }
2226
+ return
2227
+ }
2228
+
2229
+ // De-duplicate suggested_slug against existing vault keys by appending
2230
+ // _2 / _3 / … if needed. Same logic as the cached-passphrase happy
2231
+ // path uses (gateway.ts ~L2402 stash command).
2232
+ const slugBase = deferred.suggested_slug || 'secret'
2233
+ const listed = defaultVaultList(passphrase)
2234
+ const existing = new Set(listed.ok ? listed.keys : [])
2235
+ let slug = slugBase
2236
+ let n = 2
2237
+ while (existing.has(slug)) slug = `${slugBase}_${n++}`
2238
+
2239
+ const write = defaultVaultWrite(slug, deferred.text, passphrase)
2240
+ if (!write.ok) {
2241
+ // Classify the failure via the structured stderr markers emitted by
2242
+ // `switchroom vault` (issue #969 P0a). If it's a recognised marker,
2243
+ // render a clean actionable message instead of dumping the raw
2244
+ // "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
2245
+ // emits — that was the misleading-error half of #968.
2246
+ //
2247
+ // Keep the deferred entry so the user can retry by tapping again
2248
+ // once the underlying condition is fixed (broker started, host
2249
+ // approval granted, etc.).
2250
+ const parsed = parseVaultCliError(write.output)
2251
+ const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
2252
+ const body = rendered.suppressRaw
2253
+ ? rendered.html
2254
+ : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
2255
+ if (cardMessageId != null) {
2256
+ await ctx.api
2257
+ .editMessageText(
2258
+ deferred.chat_id,
2259
+ cardMessageId,
2260
+ richMessage(`${body}\n\nRe-tap to retry.`),
2261
+ {
2262
+ reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
2263
+ ? buildDeferredSecretKeyboard(deferKey)
2264
+ : undefined,
2265
+ },
2266
+ )
2267
+ .catch(() => {})
2268
+ }
2269
+ return
2270
+ }
2271
+
2272
+ deferredSecrets.delete(deferKey)
2273
+ const masked = maskToken(deferred.text)
2274
+ if (cardMessageId != null) {
2275
+ await ctx.api
2276
+ .editMessageText(
2277
+ deferred.chat_id,
2278
+ cardMessageId,
2279
+ richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
2280
+ { reply_markup: { inline_keyboard: [] } },
2281
+ )
2282
+ .catch(() => {})
2283
+ }
2284
+ // Stage for follow-up rename, mirroring the cached-passphrase path.
2285
+ secretStaging.set({
2286
+ chat_id: deferred.chat_id,
2287
+ message_id: deferred.original_message_id,
2288
+ detection: {
2289
+ rule_id: 'deferred',
2290
+ matched_text: deferred.text,
2291
+ start: 0,
2292
+ end: deferred.text.length,
2293
+ confidence: 'high' as const,
2294
+ suppressed: false,
2295
+ suggested_slug: slug,
2296
+ },
2297
+ staged_at: Date.now(),
2298
+ })
2299
+ }
2300
+
2301
+ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<void> {
2302
+ const senderId = String(ctx.from?.id ?? '')
2303
+ const access = loadAccess()
2304
+ if (!access.allowFrom.includes(senderId)) {
2305
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
2306
+ return
2307
+ }
2308
+
2309
+ // Parse op:<action>:<encoded-agent>
2310
+ const parts = data.slice(3).split(':', 2) // drop "op:", then split action:agent
2311
+ if (parts.length !== 2) {
2312
+ await ctx.answerCallbackQuery({ text: 'Malformed operator-event callback.' }).catch(() => {})
2313
+ return
2314
+ }
2315
+ const [action, encodedAgent] = parts
2316
+ let agent: string
2317
+ try {
2318
+ agent = decodeURIComponent(encodedAgent)
2319
+ } catch {
2320
+ await ctx.answerCallbackQuery({ text: 'Bad agent name encoding.' }).catch(() => {})
2321
+ return
2322
+ }
2323
+ if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(agent)) {
2324
+ await ctx.answerCallbackQuery({ text: 'Invalid agent name.' }).catch(() => {})
2325
+ return
2326
+ }
2327
+
2328
+ // #1150 audit P1: extract the source card text once so every branch
2329
+ // below can append a status line via finalizeCallback. Pre-fix `dismiss`
2330
+ // and `restart` stripped the keyboard but kept the original card body
2331
+ // verbatim — operator scrolling back couldn't see what they'd decided.
2332
+ // `reauth` didn't strip the keyboard at all → re-tappable mid-flow.
2333
+ //
2334
+ // HTML-escape the extracted text before concatenation. Telegram returns
2335
+ // `msg.text` as plain UTF-8 with entities stripped — any raw `<`, `>`,
2336
+ // or `&` characters in the original `detail` (operator-events.ts
2337
+ // `unknown-4xx`/`unknown-5xx` cards routinely carry API error bodies
2338
+ // with `<`/`>` in them) would be re-parsed as HTML tags when the
2339
+ // finalizeCallback edit fires with `parseMode: 'HTML'`. Telegram
2340
+ // rejects the edit, finalizeCallback's catch swallows it, the
2341
+ // keyboard never strips, and the operator re-taps → exact bug this
2342
+ // PR is meant to fix re-introduced. Escape once here so every branch
2343
+ // gets a safe-to-reparse value. We lose the original bold/italic
2344
+ // styling on the source body — acceptable, that styling was already
2345
+ // gone the moment `msg.text` was read instead of `msg.entities`.
2346
+ // (PR #1158 round 2 — review item F.)
2347
+ const sourceMsgText = (() => {
2348
+ const msg = ctx.callbackQuery?.message
2349
+ if (!msg || !('text' in msg) || !msg.text) return ''
2350
+ return escapeHtmlForTg(msg.text)
2351
+ })()
2352
+
2353
+ switch (action) {
2354
+ case 'dismiss': {
2355
+ // #1150 audit P1: was strip-only. Now appends a status line so
2356
+ // scrollback shows the dismissal.
2357
+ const status = `\n\n✗ _Dismissed by operator._`
2358
+ await finalizeCallback(ctx, {
2359
+ ackText: 'Dismissed',
2360
+ newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2361
+ // No synthInbound — dismiss is operator-only, no model in loop.
2362
+ })
2363
+ return
2364
+ }
2365
+ case 'restart': {
2366
+ const ok = triggerSelfRestart(agent, 'inline-button-restart')
2367
+ if (ok) {
2368
+ // #1150 audit P1: was reply + editMessageReplyMarkup (two
2369
+ // separate edits). Atomic via finalizeCallback now — the
2370
+ // status line is the announcement, no separate reply needed.
2371
+ const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
2372
+ await finalizeCallback(ctx, {
2373
+ ackText: `Restarting ${agent}…`,
2374
+ newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2375
+ })
2376
+ } else {
2377
+ // Failure-path: leave the keyboard tappable so the operator
2378
+ // can retry once they've followed the manual instructions
2379
+ // below. ack toast still fires.
2380
+ await ctx.answerCallbackQuery({ text: `Restart failed for ${agent}` }).catch(() => {})
2381
+ const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
2382
+ const detail = isDocker
2383
+ ? `cross-agent restart is not supported under docker. ` +
2384
+ `Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
2385
+ : 'restart trigger failed'
2386
+ await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
2387
+ }
2388
+ return
2389
+ }
2390
+ case 'reauth': {
2391
+ // #1150 audit P1: pre-fix the operator-event card's [Reauth] button
2392
+ // stayed tappable after the reauth flow started → operator could
2393
+ // re-tap and spawn a second concurrent flow that fights the first
2394
+ // for the login URL state. Strip the keyboard and append a status
2395
+ // line; the new reauth-flow's own messages appear below the
2396
+ // collapsed card.
2397
+ const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
2398
+ await finalizeCallback(ctx, {
2399
+ ackText: `Starting reauth for ${agent}…`,
2400
+ newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2401
+ synthInbound: async () => {
2402
+ await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
2403
+ // PR3 supergroup-mode: key by (chat, thread) so an OAuth code
2404
+ // pasted into a different topic isn't mistakenly intercepted
2405
+ // as this flow's reauth code.
2406
+ const reauthThreadId = ctx.callbackQuery?.message?.message_thread_id
2407
+ pendingReauthFlows.set(
2408
+ chatKey(String(ctx.chat!.id), reauthThreadId ?? null) as string,
2409
+ { agent, startedAt: Date.now() },
2410
+ )
2411
+ },
2412
+ })
2413
+ return
2414
+ }
2415
+ case 'logs': {
2416
+ await ctx.answerCallbackQuery({ text: 'Fetching logs…' }).catch(() => {})
2417
+ // Pick the right log source for the runtime. Under docker, the
2418
+ // gateway is INSIDE the agent container — calling `docker logs`
2419
+ // requires the host's docker socket which is deliberately not
2420
+ // mounted into agent containers. Under systemd, journalctl
2421
+ // works as before. v0.7.2 fixed `case 'restart'` but left this
2422
+ // path systemd-only.
2423
+ const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
2424
+ if (isDocker) {
2425
+ await ctx.replyWithRichMessage(richMessage(
2426
+ `_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
2427
+ `Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
2428
+ ))
2429
+ return
2430
+ }
2431
+ try {
2432
+ const out = execFileSync(
2433
+ 'journalctl',
2434
+ ['--user', '-u', `switchroom-${agent}`, '-n', '30', '--no-pager', '--output=short-monotonic'],
2435
+ { encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
2436
+ ) as string
2437
+ const trimmed = out.trim().slice(-3500)
2438
+ await ctx.replyWithRichMessage(richMessage(
2439
+ trimmed
2440
+ ? `\`\`\`\n${trimmed.replace(/```/g, '`​``')}\n\`\`\``
2441
+ : `_No logs for ${agent}._`,
2442
+ ))
2443
+ } catch (err) {
2444
+ await ctx.replyWithRichMessage(richMessage(
2445
+ `**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
2446
+ ))
2447
+ }
2448
+ return
2449
+ }
2450
+ default: {
2451
+ await ctx.answerCallbackQuery({ text: `Unknown action: ${action}` }).catch(() => {})
2452
+ return
2453
+ }
2454
+ }
2455
+ }
2456
+
2457
+ // RFC H §7.3: the dashboard callback dispatcher is gone — there are
2458
+ // no auth: callback buttons in the new chat surface. We keep a no-op
2459
+ // stub so any stale pinned message that fires an `auth:*` tap is
2460
+ // silently dismissed instead of crashing the gateway.
2461
+ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
2462
+ const data = ctx.callbackQuery?.data ?? ''
2463
+ const currentAgent = getMyAgentName()
2464
+
2465
+ // auth:use:<label> — fleet-wide swap via broker.setActive (same path
2466
+ // /auth use takes from chat). Admin-gated via the broker's own
2467
+ // per-agent admin flag.
2468
+ if (data.startsWith('auth:use:')) {
2469
+ const label = data.slice('auth:use:'.length)
2470
+ if (!label) {
2471
+ try { await ctx.answerCallbackQuery({ text: 'Missing account label.', show_alert: false }) } catch { /* */ }
2472
+ return
2473
+ }
2474
+ try {
2475
+ const client = await getAuthBrokerClient(currentAgent)
2476
+ if (!client) {
2477
+ try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
2478
+ return
2479
+ }
2480
+ const result = await client.setActive(label)
2481
+ try {
2482
+ await ctx.answerCallbackQuery({
2483
+ text: `Switched fleet → ${result.active} (${result.fanned.length} agents)`,
2484
+ show_alert: false,
2485
+ })
2486
+ } catch { /* toast may fail on stale tap */ }
2487
+ // Edit the source message to reflect the new active. Leaving
2488
+ // the old keyboard intact would tempt a double-tap; we replace
2489
+ // the text + drop the keyboard so the user has to /auth again
2490
+ // to see fresh state.
2491
+ const msg = ctx.callbackQuery?.message
2492
+ if (msg) {
2493
+ // Wrap in swallowingApiCall per #1075 — stale callback-source
2494
+ // messages (deleted topic, expired) shouldn't crash the swap.
2495
+ await swallowingApiCall(
2496
+ () =>
2497
+ bot.api.editMessageText(
2498
+ msg.chat.id,
2499
+ msg.message_id,
2500
+ richMessage(
2501
+ `**Active account →** \`${result.active}\`\n` +
2502
+ `_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
2503
+ `_Tap /auth to see updated quota for the new active account._`,
2504
+ ),
2505
+ {},
2506
+ ),
2507
+ { chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
2508
+ )
2509
+ }
2510
+ } catch (err) {
2511
+ const msg = (err as Error)?.message ?? String(err)
2512
+ try {
2513
+ await ctx.answerCallbackQuery({
2514
+ text: `Switch failed: ${msg.slice(0, 180)}`,
2515
+ show_alert: true,
2516
+ })
2517
+ } catch { /* */ }
2518
+ }
2519
+ return
2520
+ }
2521
+
2522
+ // auth:refresh — re-render the /auth snapshot in-place with a fresh
2523
+ // live probe. Replaces the message body; keyboard stays. The `:demo`
2524
+ // variant re-renders with email masking intact (a ↻ tap on an
2525
+ // `/auth demo` / `/usage demo` card must not unmask mid-recording).
2526
+ if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
2527
+ const refreshDemo = data === 'auth:refresh:demo'
2528
+ // Freshness throttle: each refresh fan-fires N live api.anthropic.com
2529
+ // probes (one per account — forceLive bypasses the broker's 45s
2530
+ // probe-on-open TTL, because an explicit ↻ tap is the user asking
2531
+ // for live-now data). Without this, a user double-tapping the ↻
2532
+ // button burns through their account's RPM budget on duplicate
2533
+ // work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
2534
+ // pair.
2535
+ const refreshMsg = ctx.callbackQuery?.message
2536
+ if (refreshMsg) {
2537
+ const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
2538
+ const lastAtMs = lastAuthRefreshAtMs.get(key) ?? 0
2539
+ const sinceLastMs = Date.now() - lastAtMs
2540
+ if (sinceLastMs < AUTH_REFRESH_THROTTLE_MS) {
2541
+ const waitS = Math.ceil((AUTH_REFRESH_THROTTLE_MS - sinceLastMs) / 1000)
2542
+ try {
2543
+ await ctx.answerCallbackQuery({
2544
+ text: `Just refreshed — try again in ${waitS}s`,
2545
+ show_alert: false,
2546
+ })
2547
+ } catch { /* */ }
2548
+ return
2549
+ }
2550
+ lastAuthRefreshAtMs.set(key, Date.now())
2551
+ }
2552
+ try {
2553
+ const client = await getAuthBrokerClient(currentAgent)
2554
+ if (!client) {
2555
+ try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
2556
+ return
2557
+ }
2558
+ const state = await client.listState()
2559
+ // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
2560
+ // forceLive=true: an explicit ↻ tap must bypass the broker's
2561
+ // probe-on-open TTL — pre-fix, a tap inside the TTL window served
2562
+ // the cached snapshot while stamping "Live · refreshed 0s ago".
2563
+ const probeResp = state.accounts.length > 0
2564
+ ? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
2565
+ : { results: [] }
2566
+ // #2495 Change 2 — even under forceLive a failed upstream probe falls
2567
+ // back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
2568
+ // instead of a false live stamp, same as the /auth and /usage paths.
2569
+ const { quotas, staleCachedAtMs } = zipProbeResults(
2570
+ state.accounts.map((a) => a.label),
2571
+ probeResp.results,
2572
+ )
2573
+ const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
2574
+ const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
2575
+ '../auth-snapshot-format.js'
2576
+ )
2577
+ const snapshots = buildSnapshotsFromState(state, quotas)
2578
+ // Single clock for card body + keyboard so health classification
2579
+ // can't disagree between the two (#2495 folded nit A).
2580
+ const renderNow = new Date()
2581
+ const text = renderAuthSnapshotFormat2(snapshots, {
2582
+ tz,
2583
+ now: renderNow,
2584
+ demo: refreshDemo,
2585
+ // Honesty backstop (same as /usage): a TOTAL probe failure (zero
2586
+ // result rows, nothing served from cache) renders an explicit
2587
+ // "probe failed" marker instead of a false "Live" footer next to
2588
+ // no-data rows.
2589
+ ...(staleCachedAtMs != null
2590
+ ? { staleCachedAtMs }
2591
+ : probeResp.results.length > 0
2592
+ ? { liveProbedAtMs: renderNow.getTime() }
2593
+ : { probeFailed: true }),
2594
+ })
2595
+ const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
2596
+ const inline_keyboard = kbRows.map((row) =>
2597
+ row.map((b) => {
2598
+ if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
2599
+ if (b.insertText) return { text: b.text, switch_inline_query_current_chat: b.insertText }
2600
+ return { text: b.text, callback_data: 'auth:noop' }
2601
+ }),
2602
+ )
2603
+ const msg = ctx.callbackQuery?.message
2604
+ if (msg) {
2605
+ await swallowingApiCall(
2606
+ () =>
2607
+ bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
2608
+ reply_markup: { inline_keyboard },
2609
+ }),
2610
+ { chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
2611
+ )
2612
+ }
2613
+ try { await ctx.answerCallbackQuery({ text: 'Refreshed.', show_alert: false }) } catch { /* */ }
2614
+ } catch (err) {
2615
+ const msg = (err as Error)?.message ?? String(err)
2616
+ try {
2617
+ await ctx.answerCallbackQuery({
2618
+ text: `Refresh failed: ${msg.slice(0, 180)}`,
2619
+ show_alert: true,
2620
+ })
2621
+ } catch { /* */ }
2622
+ }
2623
+ return
2624
+ }
2625
+
2626
+ // Unknown auth:* — likely from a too-old message. Dismiss with a
2627
+ // hint pointing at the canonical re-render verb.
2628
+ try {
2629
+ await ctx.answerCallbackQuery({
2630
+ text: 'Unknown auth button. Send /auth for current state.',
2631
+ show_alert: false,
2632
+ })
2633
+ } catch { /* */ }
2634
+ }
2635
+
2636
+ return {
2637
+ handleVaultRecentDenialCallback,
2638
+ performVaultAccessApproval,
2639
+ handleSkillProposalCallback,
2640
+ handleMentalModelProposeCallback,
2641
+ handleVaultRequestAccessCallback,
2642
+ handleVaultRequestSaveCallback,
2643
+ handleVaultDeferCallback,
2644
+ parseGrantDuration,
2645
+ formatGrantExpiry,
2646
+ buildGrantAgentKeyboard,
2647
+ buildGrantKeysKeyboard,
2648
+ buildGrantDurationKeyboard,
2649
+ buildGrantConfirmKeyboard,
2650
+ startGrantWizardStep1,
2651
+ grantWizardStep2,
2652
+ grantWizardStep3,
2653
+ grantWizardConfirm,
2654
+ executeGrantWizard,
2655
+ handleVaultGrantCallback,
2656
+ executeDeferredSecretSave,
2657
+ handleOperatorEventCallback,
2658
+ handleAuthDashboardCallback,
2659
+ }
2660
+ }