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
@@ -135,7 +135,17 @@ import {
135
135
  buildSecretRequestTimeoutInbound,
136
136
  buildMentalModelProposeTimeoutInbound,
137
137
  } from './approval-timeout-inbound-builders.js'
138
- import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
138
+ import { expirePendingCard } from './pending-card-expiry.js'
139
+ import { createSweepableCardStore } from './approval-card-stores.js'
140
+ import {
141
+ createCallbackQueryHandlers,
142
+ type PendingVaultOp,
143
+ type DeferredSecret,
144
+ type PendingVaultRequestSave,
145
+ type PendingVaultRequestAccess,
146
+ type PendingMentalModelPropose,
147
+ } from './callback-query-handlers.js'
148
+ import { createSweepableStore, createPlainStore } from './pending-state-stores.js'
139
149
  import {
140
150
  isPermissionRearmEnabled,
141
151
  permissionRearmGraceMs,
@@ -173,8 +183,6 @@ import {
173
183
  createRetryApiCall,
174
184
  createSwallowingRetryApiCall,
175
185
  retryWithThreadFallback,
176
- isHtmlParseRejectError,
177
- isMessageTooLongError,
178
186
  } from '../retry-api-call.js'
179
187
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
180
188
  import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
@@ -280,9 +288,16 @@ const REPLY_TO_TEXT_MAX = 200
280
288
  // #1161 silent-end fallback text now lives in ../silent-end.ts
281
289
  // (`silentEndFallbackText`, imported above) so the transport-boundary
282
290
  // tests exercise the real string — see PR #2892.
283
- import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
291
+ import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
284
292
  import { richMessage } from '../rich-send.js'
285
293
  import { scrubVoice } from '../text-voice-scrub.js'
294
+ import {
295
+ normalizeOutboundBody,
296
+ computeEffectiveText,
297
+ computeReplyChunks,
298
+ sendReplyChunks,
299
+ type ReplyChunkSendDeps,
300
+ } from './outbound-send-path.js'
286
301
  import {
287
302
  validateInlineKeyboard,
288
303
  type AnyButton,
@@ -301,11 +316,8 @@ import {
301
316
  statusPairedText as buildStatusPairedText,
302
317
  statusPendingText as buildStatusPendingText,
303
318
  statusUnpairedText as buildStatusUnpairedText,
304
- switchroomHelpText as buildSwitchroomHelpText,
305
319
  restartAckText as buildRestartAckText,
306
320
  newSessionAckText as buildNewSessionAckText,
307
- TELEGRAM_BASE_COMMANDS,
308
- TELEGRAM_SWITCHROOM_COMMANDS,
309
321
  type AgentMetadata, type AuthSummary, type StatusProbeRow,
310
322
  } from '../welcome-text.js'
311
323
  import {
@@ -405,6 +417,8 @@ import {
405
417
  type EffortCommandDeps,
406
418
  type EffortMenuReply,
407
419
  } from './effort-command.js'
420
+ import { registerSwitchroomBotCommands } from './register-bot-commands.js'
421
+ import { registerOpsInfoCommands } from './bot-commands-ops-info.js'
408
422
  import { applyEffort } from '../../src/agents/effort-picker.js'
409
423
  import { type BannerState } from '../slot-banner.js'
410
424
  import { refreshBanner } from '../slot-banner-driver.js'
@@ -514,29 +528,13 @@ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
514
528
  import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
515
529
  import { probeGateParity } from './gate-parity-probe.js'
516
530
  import { maybeFireWarmup } from './prefix-warmup.js'
517
- import {
518
- buildVaultGrantApprovedInbound,
519
- buildVaultGrantApprovedCardText,
520
- buildVaultGrantDeniedInbound,
521
- buildVaultSaveCompletedInbound,
522
- buildVaultSaveFailedInbound,
523
- buildVaultSaveDiscardedInbound,
524
- } from './vault-grant-inbound-builders.js'
525
531
  import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
526
- import {
527
- resolveMentalModelProposal,
528
- type MentalModelPendingProposal,
529
- } from './mental-model-propose-resolve.js'
530
532
  import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
531
533
  import {
532
- parseSkillProposalCallback,
533
- buildSkillProposalApplyInbound,
534
534
  renderSkillProposalCard,
535
535
  skillProposalKeyboard,
536
536
  } from './skill-proposal-card.js'
537
537
  import {
538
- getProposal as getSkillProposal,
539
- setProposalStatus as setSkillProposalStatus,
540
538
  enqueueProposal as enqueueSkillProposal,
541
539
  isSuppressed as isSkillProposalSuppressed,
542
540
  } from '../../src/self-improve/skill-proposals.js'
@@ -661,7 +659,7 @@ import {
661
659
  isLiveCorroboration,
662
660
  } from '../quota-watch.js'
663
661
  import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
664
- import { maskUsername, maskVaultKey } from '../demo-mask.js'
662
+ import { maskUsername } from '../demo-mask.js'
665
663
  import {
666
664
  writeTurnActiveMarker,
667
665
  touchTurnActiveMarker,
@@ -683,10 +681,8 @@ import {
683
681
  statusViaBroker,
684
682
  lockViaBroker,
685
683
  unlockViaBroker,
686
- mintGrantViaBroker,
687
684
  listViaBroker,
688
685
  listGrantsViaBroker,
689
- revokeGrantViaBroker,
690
686
  } from '../../src/vault/broker/client.js'
691
687
  import { emitLinearAgentActivity, createLinearIssue, buildLinearAuthDeadMessage, brokerRefreshIO, type LinearAuthDeadReason } from './linear-activity.js'
692
688
  import { runLinearAgentSetup } from './linear-setup.js'
@@ -698,7 +694,6 @@ import {
698
694
  approvalRecord,
699
695
  } from '../../src/vault/approvals/client.js'
700
696
  import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
701
- import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
702
697
  import {
703
698
  openTurnsDb,
704
699
  markOrphanedWithTimeoutClassification,
@@ -4939,24 +4934,8 @@ function probeAvailableReactions(chatId: string): void {
4939
4934
  // ─── Text chunking ────────────────────────────────────────────────────────
4940
4935
  const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
4941
4936
 
4942
- function chunk(text: string, limit: number, mode: 'length' | 'newline'): string[] {
4943
- if (text.length <= limit) return [text]
4944
- const out: string[] = []
4945
- let rest = text
4946
- while (rest.length > limit) {
4947
- let cut = limit
4948
- if (mode === 'newline') {
4949
- const para = rest.lastIndexOf('\n\n', limit)
4950
- const line = rest.lastIndexOf('\n', limit)
4951
- const space = rest.lastIndexOf(' ', limit)
4952
- cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
4953
- }
4954
- out.push(rest.slice(0, cut))
4955
- rest = rest.slice(cut).replace(/^\n+/, '')
4956
- }
4957
- if (rest) out.push(rest)
4958
- return out
4959
- }
4937
+ // The length/newline text splitter moved to outbound-send-path.ts (#2996) as
4938
+ // `chunkText`; the sole gateway caller now goes through `computeReplyChunks`.
4960
4939
 
4961
4940
  // ─── Typing indicator ─────────────────────────────────────────────────────
4962
4941
  // All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
@@ -5409,14 +5388,15 @@ const PERMISSION_CARD_ORIGIN_MAX_AGE_MS = 30 * 60_000
5409
5388
  // other field finds no entry and falls through to a real operator card.
5410
5389
  // Single-shot (deleted on match) + 30s TTL sweep so a stale correlation
5411
5390
  // can't be replayed.
5412
- const pendingAlwaysAllowCorrelations = new Map<string, { agentName: string; rule: string; unifiedDiff: string; createdAt: number }>()
5413
5391
  const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
5392
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2): the
5393
+ // store owns the Map + co-locates the delete-past-TTL sweep. The TTL and its
5394
+ // comparison direction stay here in the injected predicate (byte-identical).
5395
+ const pendingAlwaysAllowCorrelations = createSweepableStore<{ agentName: string; rule: string; unifiedDiff: string; createdAt: number }>(
5396
+ (entry, now) => now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS,
5397
+ )
5414
5398
  function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
5415
- for (const [key, entry] of pendingAlwaysAllowCorrelations) {
5416
- if (now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS) {
5417
- pendingAlwaysAllowCorrelations.delete(key)
5418
- }
5419
- }
5399
+ pendingAlwaysAllowCorrelations.sweep(now)
5420
5400
  }
5421
5401
 
5422
5402
  // Sibling of pendingAlwaysAllowCorrelations for the agent-proposes →
@@ -5439,16 +5419,17 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
5439
5419
  // slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
5440
5420
  // for an edit the operator already approved. Size it to the hostd budget.
5441
5421
  const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
5442
- const pendingMentalModelCorrelations = new Map<string, { agentName: string; unifiedDiff: string; createdAt: number }>()
5422
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Same
5423
+ // pattern as pendingAlwaysAllowCorrelations, but the predicate closes over the
5424
+ // larger 720s TTL that must outlive the whole config-edit approval budget.
5425
+ const pendingMentalModelCorrelations = createSweepableStore<{ agentName: string; unifiedDiff: string; createdAt: number }>(
5426
+ (entry, now) => now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS,
5427
+ )
5443
5428
  function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
5444
5429
  return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
5445
5430
  }
5446
5431
  function sweepStaleMentalModelCorrelations(now = Date.now()): void {
5447
- for (const [key, entry] of pendingMentalModelCorrelations) {
5448
- if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
5449
- pendingMentalModelCorrelations.delete(key)
5450
- }
5451
- }
5432
+ pendingMentalModelCorrelations.sweep(now)
5452
5433
  }
5453
5434
 
5454
5435
  // Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
@@ -5486,10 +5467,19 @@ interface PendingAskUser {
5486
5467
  timer: ReturnType<typeof setTimeout>
5487
5468
  startedAt: number
5488
5469
  }
5489
- const pendingAskUser = new Map<string, PendingAskUser>()
5490
-
5491
- // Reauth flows
5492
- const pendingReauthFlows = new Map<string, { agent: string; startedAt: number }>()
5470
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Plain
5471
+ // store (no TTL sweep): entries are bounded by per-entry timers and cleared on
5472
+ // resolution / shutdown / chat-close, not by the reaper.
5473
+ const pendingAskUser = createPlainStore<PendingAskUser>()
5474
+
5475
+ // Reauth flows. Storage extracted to pending-state-stores.ts (#2996 Phase 3
5476
+ // step 2): the store owns the Map + co-locates the delete-past-TTL sweep the
5477
+ // reaper drove inline. Direction preserved: now - startedAt > TTL. The sweep
5478
+ // call stays in the SAME reaper position (first, ahead of the OAuth-code
5479
+ // cluster) so the awaitingAuthCodeAt contiguity pin is unaffected.
5480
+ const pendingReauthFlows = createSweepableStore<{ agent: string; startedAt: number }>(
5481
+ (v, now) => now - v.startedAt > REAUTH_INTERCEPT_TTL_MS,
5482
+ )
5493
5483
  const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
5494
5484
 
5495
5485
  // #710: per-message agent-button metadata (ack_text / single_use). Keyed by
@@ -5501,7 +5491,11 @@ const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
5501
5491
  // design: when this map is empty (e.g. fresh process) the defaults apply
5502
5492
  // (`'✓ received'` toast + strip keyboard) — the agent only loses any
5503
5493
  // custom ack_text override.
5504
- const agentButtonMeta = new Map<string, Map<string, AgentButtonMeta>>()
5494
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Plain
5495
+ // store (no TTL sweep): bounded by the AGENT_BUTTON_META_MAX LRU cap enforced
5496
+ // in rememberAgentButtonMeta, not the reaper. keys().next().value gives the
5497
+ // oldest insertion for eviction — Map-surface parity preserved.
5498
+ const agentButtonMeta = createPlainStore<Map<string, AgentButtonMeta>>()
5505
5499
  const AGENT_BUTTON_META_MAX = 1000
5506
5500
  function rememberAgentButtonMeta(
5507
5501
  chatId: string | number,
@@ -5519,7 +5513,11 @@ function rememberAgentButtonMeta(
5519
5513
  }
5520
5514
 
5521
5515
  // Vault
5522
- const vaultPassphraseCache = new Map<string, { passphrase: string; expiresAt: number }>()
5516
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Absolute
5517
+ // expiry: the sweep deletes when now > expiresAt (direction preserved verbatim).
5518
+ const vaultPassphraseCache = createSweepableStore<{ passphrase: string; expiresAt: number }>(
5519
+ (v, now) => now > v.expiresAt,
5520
+ )
5523
5521
  const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
5524
5522
 
5525
5523
  /**
@@ -5599,249 +5597,70 @@ export function initVaultApprovalPosture(): void {
5599
5597
  const VAULT_KEY_REGEX = /^[A-Za-z0-9_./-]{1,200}$/
5600
5598
  /** Human-readable label embedded in error messages and rename prompts. */
5601
5599
  const VAULT_KEY_REGEX_LABEL = "[A-Za-z0-9_./-]{1,200}"
5602
- type PendingVaultOp =
5603
- | { kind: 'passphrase'; op: 'list' | 'get' | 'delete' | 'set'; key?: string; startedAt: number }
5604
- | { kind: 'value'; op: 'set'; key: string; passphrase: string; startedAt: number }
5605
- // Issue #44: passphrase entry triggered by tapping "🔓 Unlock vault & save"
5606
- // on a deferred-secret card. After the passphrase is cached we look up the
5607
- // held secret by deferKey and write it directly — no re-paste required.
5608
- | {
5609
- kind: 'passphrase-for-deferred'
5610
- deferKey: string
5611
- cardChatId: string
5612
- cardMessageId: number
5613
- startedAt: number
5614
- }
5615
- // Issue #158: passphrase collected for /vault unlock — sent directly to the
5616
- // broker unlock socket, never logged or cached beyond the op itself.
5617
- | { kind: 'unlock'; startedAt: number }
5618
- // Issue #227: inline-keyboard wizard for /vault grant
5619
- | {
5620
- kind: 'grant-wizard'
5621
- step: 'agent' | 'keys' | 'duration' | 'confirm'
5622
- wizardMsgId?: number // message to edit for each step
5623
- agent?: string
5624
- selectedKeys?: string[] // keys toggled on in step 2
5625
- availableKeys?: string[] // list fetched from broker
5626
- ttlSeconds?: number | null // null = never expires
5627
- expiresLabel?: string // human-readable label for confirmation
5628
- description?: string
5629
- awaitingCustomDuration?: boolean // true while waiting for text reply
5630
- /**
5631
- * Approval-kernel request_id minted at the wizard confirm step
5632
- * (MIGRATION.md §2, Phase 1 dual-dispatch — audit-only, advisory).
5633
- * When set, `vg:generate` ALSO consumes + records an `allow_once`
5634
- * decision on the kernel; `vg:cancel` records a `deny`. Cards in
5635
- * flight from before this PR landed have it `undefined` and the
5636
- * legacy `mintGrantViaBroker` runs alone — no kernel write. After
5637
- * 1-2 releases the legacy-only branch can be removed (#833 Phase 2
5638
- * is the enforcing flip).
5639
- */
5640
- kernel_request_id?: string
5641
- startedAt: number
5642
- }
5643
- // Issue #228: waiting for confirmation before revoking a grant.
5644
- | { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
5645
- // Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
5646
- // the next message becomes the new key name for the staged save.
5647
- | { kind: 'rename-vault-save'; stageId: string; startedAt: number }
5648
- // Issue #1012 Phase 2 follow-up: operator tapped Approve on a
5649
- // vault_request_access card without first unlocking the vault. The
5650
- // next message becomes the passphrase — we cache it, delete the
5651
- // passphrase message, and auto-resume the approval mint flow without
5652
- // making the operator tap Approve a second time. Mirrors the
5653
- // `passphrase-for-deferred` flow from #44.
5654
- //
5655
- // #1051: `items` is a queue so concurrent Approve taps (operator
5656
- // taps card 2 before typing passphrase for card 1) don't strand
5657
- // earlier stages. On passphrase reply we process all queued items
5658
- // sequentially. Each item carries its own stageId + card refs;
5659
- // they're all in the same chat by construction (pendingVaultOps
5660
- // map is keyed by chat_id).
5661
- | {
5662
- kind: 'passphrase-for-access-approve'
5663
- items: Array<{
5664
- stageId: string
5665
- cardChatId: string
5666
- cardMessageId: number
5667
- senderId: string
5668
- }>
5669
- startedAt: number
5670
- }
5600
+ // PendingVaultOp moved to callback-query-handlers.ts (#2996 Phase 5).
5671
5601
  const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
5672
- const pendingVaultOps = new Map<string, PendingVaultOp>()
5602
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2): the
5603
+ // store owns the Map + co-locates the delete-past-TTL sweep the reaper drove
5604
+ // inline. TTL + direction stay here (byte-identical: now - startedAt > TTL).
5605
+ const pendingVaultOps = createSweepableStore<PendingVaultOp>(
5606
+ (v, now) => now - v.startedAt > VAULT_INPUT_TTL_MS,
5607
+ )
5673
5608
 
5674
5609
  // Secret-detection staging: ambiguous hits the user must confirm before we
5675
5610
  // store/delete. Also holds the deferred "we need a passphrase before we can
5676
5611
  // store this high-confidence hit" cases so the re-run after passphrase entry
5677
5612
  // is seamless.
5678
5613
  const secretStaging = new StagingMap()
5679
- interface DeferredSecret {
5680
- chat_id: string
5681
- original_message_id: number
5682
- text: string
5683
- staged_at: number
5684
- /**
5685
- * Slug suggested by the detector at the time we deferred the secret.
5686
- * Captured up-front so the post-unlock auto-write doesn't have to re-run
5687
- * detection (which would have to handle the no-detection-fired case for
5688
- * Channel B context-rule defers — issue #44). Falls back to a generic
5689
- * slug if detection didn't fire.
5690
- */
5691
- suggested_slug: string
5692
- /**
5693
- * Approval-kernel request_id minted alongside the bespoke deferred-secret
5694
- * card (MIGRATION.md §1, Phase 1 dual-dispatch). When set, the
5695
- * `vd:unlock` / `vd:cancel` callback handler ALSO records the user's
5696
- * decision on the kernel side via `approvalConsume` + `approvalRecord`,
5697
- * so the audit log captures the unlock event.
5698
- *
5699
- * `undefined` on cards built before this PR landed (in-flight at deploy
5700
- * time) — the legacy handler runs alone, no kernel record. After ~1-2
5701
- * releases the legacy-only branch can be removed (separate cleanup PR).
5702
- */
5703
- kernel_request_id?: string
5704
- }
5705
- const deferredSecrets = new Map<string, DeferredSecret>()
5614
+ // DeferredSecret moved to callback-query-handlers.ts (#2996 Phase 5).
5615
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). The
5616
+ // isExpired predicate reads DEFERRED_SECRET_TTL_MS lazily at sweep time (it is
5617
+ // declared below this const but only referenced when the reaper sweeps, so the
5618
+ // TDZ never bites). Direction preserved: now - staged_at > TTL.
5619
+ const deferredSecrets = createSweepableStore<DeferredSecret>(
5620
+ (v, now) => now - v.staged_at > DEFERRED_SECRET_TTL_MS,
5621
+ )
5706
5622
 
5707
- /**
5708
- * Agent-initiated save staging (issue #969 P1a). When an agent calls the
5709
- * `vault_request_save` MCP tool, we stage the value here, render an
5710
- * approval card to the user, and write to vault only on tap. The value
5711
- * is held in gateway memory ONLY — never echoed back to the agent and
5712
- * never logged.
5713
- */
5714
- interface PendingVaultRequestSave {
5715
- /** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
5716
- agent: string
5717
- /** Chat to edit when the user taps. */
5718
- chat_id: string
5719
- /** Card message id (filled in after we send the card). */
5720
- card_message_id?: number
5721
- /** Supergroup forum topic the agent was working in when it requested the
5722
- * save — carried into the save-outcome inbound so the resumed reply lands
5723
- * back in that topic, not General. */
5724
- threadId?: number
5725
- /** Currently-suggested slug; may be renamed by the user. */
5726
- key: string
5727
- /** Storage shape — 'string' (default) or 'binary'. */
5728
- kind: 'string' | 'binary'
5729
- /** The secret value, held in memory until the user approves/discards. */
5730
- value: string
5731
- /** Optional rationale shown on the card. */
5732
- why?: string
5733
- /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
5734
- staged_at: number
5735
- /** Set on entries RESTORED from disk after a gateway restart. The staged
5736
- * secret `value` is held in memory only (never persisted — secrets
5737
- * hygiene), so a restored entry has an empty value and cannot complete the
5738
- * write. A Save tap on such a card degrades gracefully: it tells the agent
5739
- * the value was lost to a restart instead of writing an empty secret. */
5740
- restoredWithoutValue?: boolean
5741
- }
5742
- const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
5623
+ // PendingVaultRequestSave moved to callback-query-handlers.ts (#2996 Phase 5).
5743
5624
  // Gateway-side reap window for a staged vault-save card. Tracks the operator
5744
5625
  // approval-card lifetime (config-driven, 60-min default) so the reap never
5745
5626
  // races ahead of the card the operator is still looking at.
5746
5627
  const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
5747
- function sweepPendingVaultRequestSaves(now = Date.now()): void {
5748
- sweepExpiredEntries(
5749
- pendingVaultRequestSaves,
5750
- (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
5751
- expireVaultSaveCard,
5752
- now,
5753
- cardExpiryLog,
5754
- )
5755
- }
5628
+ // Storage extracted to approval-card-stores.ts (#2996 Phase 3): the store owns
5629
+ // the Map + co-locates the TTL sweep (`.sweep(now)`); expiry LOGIC
5630
+ // (expireVaultSaveCard) is still gateway-side, injected as a thunk. Call sites
5631
+ // (get/set/delete/iteration/restore) are byte-identical same variable, same
5632
+ // Map surface.
5633
+ const pendingVaultRequestSaves = createSweepableCardStore<PendingVaultRequestSave>({
5634
+ isExpired: (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
5635
+ expire: () => expireVaultSaveCard,
5636
+ log: () => cardExpiryLog,
5637
+ })
5756
5638
 
5757
- /**
5758
- * Issue #1012 — agent-initiated vault ACL request. The agent calls
5759
- * `vault_request_access` when it hits VAULT-BROKER-DENIED (or
5760
- * preemptively, when it knows it'll need a key it doesn't yet have).
5761
- * The card carries [Approve] / [Deny] inline buttons; only the
5762
- * operator can mint the grant (same authorization gate as the
5763
- * existing /vault audit one-tap allow flow). The agent never sees
5764
- * the grant token directly — `mintGrantViaBroker` writes it to the
5765
- * agent's `.vault-token` file, which the agent's CLI reads on the
5766
- * next vault request.
5767
- *
5768
- * Mirrors PendingVaultRequestSave above (#969 P1a). No secret
5769
- * material is staged here — only the request metadata.
5770
- */
5771
- interface PendingVaultRequestAccess {
5772
- /** Agent that initiated the request (process.env.SWITCHROOM_AGENT_NAME). */
5773
- agent: string
5774
- /** Chat the card was rendered into; edited on tap. */
5775
- chat_id: string
5776
- /** Card message id (filled in after we send the card). */
5777
- card_message_id?: number
5778
- /** Supergroup forum topic the agent was working in when it requested (the
5779
- * card's originating thread). Carried into the grant-outcome inbound so the
5780
- * resumed reply lands back in that topic, not General. */
5781
- threadId?: number
5782
- /** Vault key the agent wants to read. */
5783
- key: string
5784
- /** 'read' (default) or 'write'. */
5785
- scope: 'read' | 'write'
5786
- /** Optional rationale the agent supplied; rendered on the card. */
5787
- reason?: string
5788
- /** Grant TTL in seconds (max 90 days; null = never, refused). */
5789
- ttl_seconds: number
5790
- /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_ACCESS_TTL_MS. */
5791
- staged_at: number
5792
- }
5793
- const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
5639
+ // PendingVaultRequestAccess moved to callback-query-handlers.ts (#2996 Phase 5).
5794
5640
  // Gateway-side reap window for a staged vault-access card. Tracks the operator
5795
5641
  // approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
5796
5642
  const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
5797
- function sweepPendingVaultRequestAccesses(now = Date.now()): void {
5798
- sweepExpiredEntries(
5799
- pendingVaultRequestAccesses,
5800
- (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
5801
- expireVaultAccessCard,
5802
- now,
5803
- cardExpiryLog,
5804
- )
5805
- }
5643
+ // Storage extracted to approval-card-stores.ts (#2996 Phase 3) see the
5644
+ // vault-save store above for the pattern.
5645
+ const pendingVaultRequestAccesses = createSweepableCardStore<PendingVaultRequestAccess>({
5646
+ isExpired: (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
5647
+ expire: () => expireVaultAccessCard,
5648
+ log: () => cardExpiryLog,
5649
+ })
5806
5650
 
5807
- /**
5808
- * Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
5809
- * calls `mental_model_propose`; the operator taps Approve/Deny on the card.
5810
- * Mirrors PendingVaultRequestAccess — no memory content is staged here, only
5811
- * the proposed DECLARATION (name + source_query + optional knobs). On Approve
5812
- * the model becomes a first-class declared model in memory.mental_models[] via
5813
- * the operator-approved config-edit path; on Deny nothing is written.
5814
- */
5815
- interface PendingMentalModelPropose {
5816
- agent: string
5817
- chat_id: string
5818
- card_message_id?: number
5819
- threadId?: number
5820
- /** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
5821
- spec: {
5822
- name: string
5823
- source_query: string
5824
- refresh_after_consolidation?: boolean
5825
- max_tokens?: number
5826
- }
5827
- reason?: string
5828
- staged_at: number
5829
- }
5830
- const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
5651
+ // PendingMentalModelPropose moved to callback-query-handlers.ts (#2996 Phase 5).
5831
5652
  const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5832
- // Sweep expired pending proposals. For any entry past its TTL we ALSO edit the
5833
- // posted card's keyboard away, so a stale card left in the chat can't be tapped
5834
- // into a "Card expired" answer — the operator sees the expiry inline instead.
5835
- // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5836
- function sweepPendingMentalModelProposes(now = Date.now()): void {
5837
- sweepExpiredEntries(
5838
- pendingMentalModelProposes,
5839
- (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
5840
- expireMentalModelProposeCard,
5841
- now,
5842
- cardExpiryLog,
5843
- )
5844
- }
5653
+ // Storage extracted to approval-card-stores.ts (#2996 Phase 3). The store's
5654
+ // `.sweep(now)` expires past-TTL proposals; for any expired entry the injected
5655
+ // expireMentalModelProposeCard ALSO edits the posted card's keyboard away, so a
5656
+ // stale card left in the chat can't be tapped into a "Card expired" answer — the
5657
+ // operator sees the ⌛ expiry inline instead. Best-effort: card edits are
5658
+ // fire-and-forget (the entry is removed regardless).
5659
+ const pendingMentalModelProposes = createSweepableCardStore<PendingMentalModelPropose>({
5660
+ isExpired: (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
5661
+ expire: () => expireMentalModelProposeCard,
5662
+ log: () => cardExpiryLog,
5663
+ })
5845
5664
 
5846
5665
  // Sliding-window rate limit for mental-model proposals: at most
5847
5666
  // MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
@@ -6026,9 +5845,10 @@ const DEFERRED_SECRET_TTL_MS = 24 * 60 * 60_000 // 24 h — ignored one-tap card
6026
5845
  // Freshness throttle for `auth:refresh` taps. Keyed by `<chat_id>:<message_id>`
6027
5846
  // so two different snapshot messages throttle independently. Each refresh
6028
5847
  // fan-fires N live api.anthropic.com probes (one per account), so we cap
6029
- // rapid re-taps to one per AUTH_REFRESH_THROTTLE_MS.
5848
+ // rapid re-taps to one per AUTH_REFRESH_THROTTLE_MS (the constant moved to
5849
+ // callback-query-handlers.ts with the handler; this map's 60s reaper stays
5850
+ // gateway-side below).
6030
5851
  const lastAuthRefreshAtMs = new Map<string, number>()
6031
- const AUTH_REFRESH_THROTTLE_MS = 5_000
6032
5852
 
6033
5853
  // ─── TTL reaper ───────────────────────────────────────────────────────────
6034
5854
  // Pending state maps above all grow whenever a flow starts and only shrink
@@ -6285,9 +6105,9 @@ function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelProp
6285
6105
  // per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
6286
6106
  // socket, store IO error) can't skip the remaining entries or families.
6287
6107
  function sweepExpiredApprovalCards(now: number): void {
6288
- sweepPendingVaultRequestAccesses(now)
6289
- sweepPendingVaultRequestSaves(now)
6290
- sweepPendingMentalModelProposes(now)
6108
+ pendingVaultRequestAccesses.sweep(now)
6109
+ pendingVaultRequestSaves.sweep(now)
6110
+ pendingMentalModelProposes.sweep(now)
6291
6111
  sweepSecretRequests(now)
6292
6112
  }
6293
6113
 
@@ -6371,9 +6191,7 @@ function restorePendingApprovalCards(): number {
6371
6191
  const pendingStateReaper = setInterval(() => {
6372
6192
  const now = Date.now()
6373
6193
  // OAuth-code state grouped first (pinned by secret-detect-oauth-code.test.ts).
6374
- for (const [k, v] of pendingReauthFlows) {
6375
- if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) pendingReauthFlows.delete(k)
6376
- }
6194
+ pendingReauthFlows.sweep(now)
6377
6195
  for (const [k, v] of pendingAuthAddFlows) {
6378
6196
  if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) {
6379
6197
  cancelAccountAuthSession(v)
@@ -6404,9 +6222,7 @@ const pendingStateReaper = setInterval(() => {
6404
6222
  for (const [k, v] of pendingAuthRmFlows) {
6405
6223
  if (now >= v.expiresAt) pendingAuthRmFlows.delete(k)
6406
6224
  }
6407
- for (const [k, v] of pendingVaultOps) {
6408
- if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
6409
- }
6225
+ pendingVaultOps.sweep(now)
6410
6226
  for (const [k, v] of pendingPermissions) {
6411
6227
  // hostd gated fleet-mutation verbs get a longer (30-min) human-scale
6412
6228
  // decision window than the 10-min default (Bug 2 fix #2).
@@ -6485,9 +6301,7 @@ const pendingStateReaper = setInterval(() => {
6485
6301
  for (const [sig, at] of permissionTimeoutSignatures) {
6486
6302
  if (now - at > PERMISSION_DUPLICATE_WINDOW_MS) permissionTimeoutSignatures.delete(sig)
6487
6303
  }
6488
- for (const [k, v] of vaultPassphraseCache) {
6489
- if (now > v.expiresAt) vaultPassphraseCache.delete(k)
6490
- }
6304
+ vaultPassphraseCache.sweep(now)
6491
6305
  // Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
6492
6306
  // expiry; this just keeps the map from accumulating dead entries.) Persist
6493
6307
  // the removal so a restart between sweeps can't resurrect a swept grant —
@@ -6497,9 +6311,7 @@ const pendingStateReaper = setInterval(() => {
6497
6311
  if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
6498
6312
  scopedGrantStore.save(scopedGrants)
6499
6313
  }
6500
- for (const [k, v] of deferredSecrets) {
6501
- if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
6502
- }
6314
+ deferredSecrets.sweep(now)
6503
6315
  // Agent-initiated approval cards (vault_request_access / vault_request_save /
6504
6316
  // request_secret / mental_model_propose): expire past-TTL entries and WAKE
6505
6317
  // the parked agent (timeout synthetic + missed-approvals re-offer). This is
@@ -10998,37 +10810,23 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
10998
10810
  // Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
10999
10811
  // into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
11000
10812
  // code are left untouched — see normalizeParagraphBreaks).
11001
- let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
11002
- // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
11003
- // the stderr preview below, the dedup key, the send, and the history
11004
- // record. Mutates `text` so every downstream consumer sees the masked
11005
- // value, exactly like the voice scrub that follows. Runs BEFORE the
11006
- // punctuation/bold normalizers so a secret containing an em-dash or `**`
11007
- // is matched literally by the redactor before any mutation.
11008
- text = redactOutboundText(text, 'reply')
11009
- // Fleet-wide consistent formatting: normalize dashes/bullets and trip the
11010
- // over-bold guard deterministically, on code-masked text (same contract on
11011
- // reply / edit / stream paths). Runs before scrubVoice below so dashes get
11012
- // the comma treatment on every path; scrubVoice's dash telemetry
11013
- // (voice_scrub_applied) drops to ~zero here as a result — deliberate.
11014
- text = stripExcessBold(normalizePunctuation(text))
11015
- // Voice scrub (#1683): replace em / en dashes with commas / periods.
11016
- // Runs BEFORE outboundDedup so retries see the scrubbed key, and on the
11017
- // raw markdown text (the scrubber does its own code-region parking so
11018
- // dashes inside fences/inline code are preserved). Kill switch:
11019
- // `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
11020
- {
11021
- const scrub = scrubVoice(text)
11022
- if (scrub.replaced > 0) {
11023
- text = scrub.scrubbed
11024
- emitRuntimeMetric({
11025
- kind: 'voice_scrub_applied',
11026
- chatKey: statusKey(chat_id, args.message_thread_id != null
11027
- ? Number(args.message_thread_id) : undefined),
11028
- replaced: scrub.replaced,
11029
- site: 'reply',
11030
- })
11031
- }
10813
+ // Outbound text pipeline (#2996 §3B): normalize → redact → punctuation/bold
10814
+ // voice-scrub, extracted verbatim into outbound-send-path.ts. The order is
10815
+ // load-bearing (secret scrub BEFORE the punctuation/bold normalizers so a
10816
+ // secret with an em-dash or `**` is matched literally; voice scrub last so
10817
+ // retries see the scrubbed dedup key). The metric side effect (fired on a
10818
+ // non-zero voice-scrub replacement) stays here the pure module returns the
10819
+ // replacement count and the gateway emits.
10820
+ const _normalized = normalizeOutboundBody(rawText, 'reply', redactOutboundText)
10821
+ let text = _normalized.text
10822
+ if (_normalized.voiceReplaced > 0) {
10823
+ emitRuntimeMetric({
10824
+ kind: 'voice_scrub_applied',
10825
+ chatKey: statusKey(chat_id, args.message_thread_id != null
10826
+ ? Number(args.message_thread_id) : undefined),
10827
+ replaced: _normalized.voiceReplaced,
10828
+ site: 'reply',
10829
+ })
11032
10830
  }
11033
10831
  process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
11034
10832
  // #2527: emit time_to_first_text_reply_ms on the FIRST text reply of each
@@ -11249,7 +11047,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11249
11047
  // jammed together — unlike the old HTML path. Inject a visible blank-line
11250
11048
  // spacer into prose `\n\n` gaps on the rich path only. The literal
11251
11049
  // (`format:'text'`) path must stay byte-exact, so it is left untouched.
11252
- const effectiveText: string = literalText ? text : addParagraphSpacers(text)
11050
+ const effectiveText: string = computeEffectiveText(text, literalText)
11253
11051
 
11254
11052
  assertAllowedChat(chat_id)
11255
11053
 
@@ -11313,9 +11111,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11313
11111
 
11314
11112
  const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
11315
11113
  const replyMode = access.replyToMode ?? 'first'
11316
- const chunks = literalText
11317
- ? chunk(effectiveText, limit, access.chunkMode ?? 'length')
11318
- : splitMarkdownChunks(effectiveText, limit)
11114
+ const chunks = computeReplyChunks({
11115
+ effectiveText,
11116
+ literalText,
11117
+ limit,
11118
+ chunkMode: access.chunkMode ?? 'length',
11119
+ })
11319
11120
  const sentIds: number[] = []
11320
11121
 
11321
11122
  // Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
@@ -11700,162 +11501,83 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11700
11501
  }
11701
11502
  }
11702
11503
 
11504
+ // #2996 step 1 — the chunk-send loop (with its THREAD_NOT_FOUND / oversize
11505
+ // re-split / parse-reject fallback ladder and partial-failure contract) is
11506
+ // relocated verbatim to outbound-send-path.ts's `sendReplyChunks` so it is
11507
+ // unit-testable against a fake bot API (gateway.ts is not importable —
11508
+ // Bun.listen + boot logic run at import). The raw `bot.api.*` calls stay HERE
11509
+ // as thin injected adapters (retry wrapping + allow-raw-bot-api markers
11510
+ // preserved), so the module is bot-agnostic and the check-bot-api-wrapping
11511
+ // allowlist is unchanged. The caller still builds the per-chunk option shape
11512
+ // (byte-identical). `sentIds` is threaded by reference; `threadId` /
11513
+ // `previewMessageId` come back for the file-send + history code below.
11514
+ const chunkSendDeps: ReplyChunkSendDeps = {
11515
+ sendRich: (opts, body, tid) =>
11516
+ robustApiCall(
11517
+ // allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
11518
+ () => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
11519
+ { threadId: tid, chat_id },
11520
+ ),
11521
+ sendLiteral: (opts, txt, tid) =>
11522
+ robustApiCall(
11523
+ // allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
11524
+ () => lockedBot.api.sendMessage(chat_id, txt, opts as never),
11525
+ { threadId: tid, chat_id },
11526
+ ),
11527
+ sendLiteralRaw: (opts, txt) =>
11528
+ // allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
11529
+ lockedBot.api.sendMessage(chat_id, txt, opts as never),
11530
+ sendRichRaw: (opts, body) =>
11531
+ // allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop's own classification on an already-classified length failure
11532
+ lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
11533
+ editPreview: (mid, body, opts, tid) =>
11534
+ robustApiCall(
11535
+ // allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
11536
+ () => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
11537
+ { threadId: tid, chat_id },
11538
+ ),
11539
+ richMessage,
11540
+ logOutbound,
11541
+ deleteStalePreview,
11542
+ stderr: (s: string) => { process.stderr.write(s) },
11543
+ }
11703
11544
  try {
11704
- for (let i = 0; i < chunks.length; i++) {
11705
- // PR-C2: voice-only mode with a successful synthesis suppresses the
11706
- // text body — the spoken voice note IS the reply. Bail before the
11707
- // first chunk send (sentIds stays empty for text); the voice send
11708
- // below lands the answer. Any other mode (voice+text, or voice-only
11709
- // that fell back) sends the text chunks as normal.
11710
- if (suppressText) break
11711
- const shouldReplyTo =
11712
- reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
11713
- const isLastChunk = i === chunks.length - 1
11714
- const sendOpts = {
11715
- ...(shouldReplyTo
11716
- ? {
11717
- reply_parameters: {
11718
- message_id: reply_to,
11719
- ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
11720
- },
11721
- }
11722
- : {}),
11723
- ...(threadId != null ? { message_thread_id: threadId } : {}),
11724
- ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
11725
- ...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
11726
- ...(protectContent ? { protect_content: true } : {}),
11727
- ...(disableNotification ? { disable_notification: true } : {}),
11728
- }
11729
-
11730
- // The body argument for send/edit: a rich-markdown wrapper on the
11731
- // default path, or the literal string for `format:'text'` (#2669).
11732
- const richOrPlain = (s: string) => (literalText ? s : richMessage(s))
11733
-
11734
- if (i === 0 && previewMessageId != null) {
11545
+ const _sendResult = await sendReplyChunks(chunkSendDeps, {
11546
+ chatId: chat_id,
11547
+ chunks,
11548
+ literalText,
11549
+ suppressText,
11550
+ threadId,
11551
+ previewMessageId,
11552
+ sentIds,
11553
+ buildSendOpts: (i, isLastChunk, tid) => {
11554
+ const shouldReplyTo =
11555
+ reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
11556
+ return {
11557
+ ...(shouldReplyTo
11558
+ ? {
11559
+ reply_parameters: {
11560
+ message_id: reply_to,
11561
+ ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
11562
+ },
11563
+ }
11564
+ : {}),
11565
+ ...(tid != null ? { message_thread_id: tid } : {}),
11566
+ ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
11567
+ ...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
11568
+ ...(protectContent ? { protect_content: true } : {}),
11569
+ ...(disableNotification ? { disable_notification: true } : {}),
11570
+ }
11571
+ },
11572
+ buildPreviewEditOpts: (isLastChunk) => {
11735
11573
  const editOpts: Record<string, unknown> = {}
11736
11574
  if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
11737
11575
  if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
11738
- try {
11739
- await robustApiCall(
11740
- () => lockedBot.api.editMessageText(chat_id, previewMessageId!, richOrPlain(chunks[i]), editOpts),
11741
- { threadId, chat_id },
11742
- )
11743
- sentIds.push(previewMessageId!)
11744
- previewMessageId = null
11745
- continue
11746
- } catch (err) {
11747
- const msg = err instanceof Error ? err.message : String(err)
11748
- if (/not modified/i.test(msg)) {
11749
- sentIds.push(previewMessageId!)
11750
- previewMessageId = null
11751
- continue
11752
- }
11753
- process.stderr.write(`telegram gateway: preview edit-in-place failed (${msg}), sending fresh\n`)
11754
- await deleteStalePreview(previewMessageId!)
11755
- previewMessageId = null
11756
- }
11757
- }
11758
-
11759
- // Last-resort: resend this chunk as plain text (no rich wrapper, so
11760
- // the markdown parser never runs). Keeps thread / reply / markup
11761
- // params; only the formatting is sacrificed. Used when Telegram
11762
- // rejects our markdown — better an unformatted answer than a
11763
- // vanished one. The raw markdown source is itself readable prose, so
11764
- // we send it verbatim rather than strip anything.
11765
- const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
11766
- const plain =
11767
- chunks[i].length > 0
11768
- ? chunks[i]
11769
- : '⚠️ (a fragment could not be rendered for Telegram)'
11770
- // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse policy that just rejected the payload
11771
- const sent = await lockedBot.api.sendMessage(chat_id, plain, opts as never)
11772
- sentIds.push(sent.message_id)
11773
- logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
11774
- process.stderr.write(
11775
- `telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
11776
- )
11777
- }
11778
-
11779
- // Literal `format:'text'` sends bypass the rich parser entirely
11780
- // (plain sendMessage, no markdown). The default path ships rich
11781
- // markdown via sendRichMessage. Both resolve to a Message with a
11782
- // message_id, which is all the caller reads.
11783
- const sendChunk = (opts: Record<string, unknown>): Promise<{ message_id: number }> => {
11784
- if (literalText) {
11785
- // allow-raw-bot-api: literal format:'text' send; the chunk-loop's own THREAD_NOT_FOUND + parse-reject handling wraps this
11786
- return lockedBot.api.sendMessage(chat_id, chunks[i], opts as never)
11787
- }
11788
- // sendRichMessage does NOT accept link_preview_options (rich messages
11789
- // control previews via entity detection) — drop it for the rich path.
11790
- const richOpts = { ...opts }
11791
- delete (richOpts as { link_preview_options?: unknown }).link_preview_options
11792
- // allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; thread fallback handled in the catch below
11793
- return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
11794
- }
11795
-
11796
- // Length-error recovery: a single pre-computed chunk can still exceed the
11797
- // wire cap when splitMarkdownChunks hit an indivisible region and emitted
11798
- // it whole (a giant fenced block, a no-boundary blob). Telegram answers
11799
- // with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
11800
- // at a harder boundary and send each piece, rather than misclassifying it
11801
- // as a parse-reject (which would resend the same oversized payload as
11802
- // plain text) or surfacing the raw 400.
11803
- const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
11804
- // Re-split at the same cap; for a truly indivisible block this still
11805
- // yields one oversized piece, but a hard character-cut on the rendered
11806
- // markdown at least keeps each delivered piece under the wire cap.
11807
- const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
11808
- const pieces =
11809
- subPieces.length > 1
11810
- ? subPieces
11811
- : hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
11812
- for (let p = 0; p < pieces.length; p++) {
11813
- let sent: { message_id: number }
11814
- if (literalText) {
11815
- // allow-raw-bot-api: length-error re-split last resort (literal text); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
11816
- sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
11817
- } else {
11818
- const ro = { ...opts }
11819
- delete (ro as { link_preview_options?: unknown }).link_preview_options
11820
- // allow-raw-bot-api: length-error re-split last resort (rich); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
11821
- sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
11822
- }
11823
- sentIds.push(sent.message_id)
11824
- logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
11825
- }
11826
- process.stderr.write(
11827
- `telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
11828
- )
11829
- }
11830
-
11831
- try {
11832
- const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
11833
- sentIds.push(sent.message_id)
11834
- logOutbound('reply', chat_id, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
11835
- } catch (err) {
11836
- if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
11837
- threadId = undefined
11838
- const retryOpts = { ...sendOpts }
11839
- delete (retryOpts as any).message_thread_id
11840
- try {
11841
- const sent = await sendChunk(retryOpts)
11842
- sentIds.push(sent.message_id)
11843
- } catch (retryErr) {
11844
- // Thread dropped, AND another failure: length → re-split,
11845
- // parse-reject → plain text, else propagate.
11846
- if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
11847
- else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
11848
- else throw retryErr
11849
- }
11850
- } else if (isMessageTooLongError(err)) {
11851
- await sendChunkResplit(sendOpts)
11852
- } else if (isHtmlParseRejectError(err)) {
11853
- await sendChunkPlainText(sendOpts)
11854
- } else {
11855
- throw err
11856
- }
11857
- }
11858
- }
11576
+ return editOpts
11577
+ },
11578
+ })
11579
+ threadId = _sendResult.threadId
11580
+ previewMessageId = _sendResult.previewMessageId
11859
11581
  } catch (err) {
11860
11582
  const msg = err instanceof Error ? err.message : String(err)
11861
11583
  throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
@@ -12699,7 +12421,7 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
12699
12421
  staged_at: Date.now(),
12700
12422
  }
12701
12423
  pendingVaultRequestSaves.set(stageId, pending)
12702
- sweepPendingVaultRequestSaves()
12424
+ pendingVaultRequestSaves.sweep(Date.now())
12703
12425
 
12704
12426
  // Send the approval card. #1075: route through retryWithThreadFallback
12705
12427
  // so a deleted topic still lands the card on the main chat instead of
@@ -12767,29 +12489,32 @@ interface PendingSecretRequest {
12767
12489
  * in that topic, not General. */
12768
12490
  threadId?: number
12769
12491
  }
12770
- // stageId -> request (lives until tapped or TTL).
12771
- const pendingSecretRequests = new Map<string, PendingSecretRequest>()
12772
12492
  // chat_id -> the armed capture: the operator's NEXT message in this chat is
12773
12493
  // the value for `key`. Set when [Provide securely] is tapped.
12774
12494
  interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
12775
- const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
12495
+ // Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Swept by
12496
+ // sweepSecretRequests (below) — a plain delete-past-TTL, no wake (transient
12497
+ // post-tap window). Direction preserved: now - armed_at > TTL.
12498
+ const armedSecretCaptures = createSweepableStore<ArmedSecretCapture>(
12499
+ (v, now) => now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS,
12500
+ )
12776
12501
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
12777
12502
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
12503
+ // stageId -> request (lives until tapped or TTL). Storage extracted to
12504
+ // approval-card-stores.ts (#2996 Phase 3): the store owns the Map + co-locates
12505
+ // the TTL sweep; expireSecretRequestCard (the wake logic) stays gateway-side.
12506
+ const pendingSecretRequests = createSweepableCardStore<PendingSecretRequest>({
12507
+ isExpired: (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
12508
+ expire: () => expireSecretRequestCard,
12509
+ log: () => cardExpiryLog,
12510
+ })
12778
12511
 
12779
12512
  function sweepSecretRequests(now = Date.now()): void {
12780
- sweepExpiredEntries(
12781
- pendingSecretRequests,
12782
- (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
12783
- expireSecretRequestCard,
12784
- now,
12785
- cardExpiryLog,
12786
- )
12513
+ pendingSecretRequests.sweep(now)
12787
12514
  // armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
12788
12515
  // only set after the operator taps [Provide securely], and the request is
12789
12516
  // no longer parked-on-a-card. Just drop stale ones — no wake needed.
12790
- for (const [k, v] of armedSecretCaptures) {
12791
- if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
12792
- }
12517
+ armedSecretCaptures.sweep(now)
12793
12518
  }
12794
12519
 
12795
12520
  function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
@@ -13189,7 +12914,7 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
13189
12914
  staged_at: Date.now(),
13190
12915
  }
13191
12916
  pendingVaultRequestAccesses.set(stageId, pending)
13192
- sweepPendingVaultRequestAccesses()
12917
+ pendingVaultRequestAccesses.sweep(Date.now())
13193
12918
 
13194
12919
  // renderVaultRequestAccessCard self-hardens its field line breaks (this card
13195
12920
  // is sent direct, bypassing the switchroomReply chokepoint).
@@ -13371,7 +13096,7 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
13371
13096
  staged_at: Date.now(),
13372
13097
  }
13373
13098
  pendingMentalModelProposes.set(stageId, pending)
13374
- sweepPendingMentalModelProposes()
13099
+ pendingMentalModelProposes.sweep(Date.now())
13375
13100
 
13376
13101
  const text = renderMentalModelProposeCard({
13377
13102
  agent: agentSlug,
@@ -18039,13 +17764,20 @@ async function handleInbound(
18039
17764
  // isSteering is now the real classification (computed at ~line 16289), so
18040
17765
  // the machine correctly distinguishes a mid-turn steer (delivered, no new
18041
17766
  // turn) from a fresh turn.
18042
- shadowEmit({
17767
+ // PR3c cutover (#2794 / #2996): the payload now carries the REAL
17768
+ // ipc-protocol InboundMessage (previously null) so the machine's
17769
+ // deliverToBridge / bufferInbound / persistInbound effects are executable,
17770
+ // and the returned effects are CAPTURED — under the cutover they are
17771
+ // dispatched below and become the authoritative deliver-vs-buffer routing.
17772
+ // The machine treats the payload as opaque, so the kill-switch shadow
17773
+ // trace is unchanged.
17774
+ const machineInboundEffects = shadowEmit({
18043
17775
  kind: 'inbound',
18044
17776
  key: statusKey(chat_id, messageThreadId) as _ChatKey,
18045
17777
  msg: {
18046
17778
  msgId: msgId ?? 0,
18047
17779
  isSteering,
18048
- payload: null,
17780
+ payload: inboundMsg,
18049
17781
  },
18050
17782
  at: Date.now(),
18051
17783
  })
@@ -18059,6 +17791,72 @@ async function handleInbound(
18059
17791
  effectiveText,
18060
17792
  })
18061
17793
 
17794
+ // ── PR3c inbound cutover (#2794 / #2996 item 1) ────────────────────────
17795
+ // The machine's effect list is now AUTHORITATIVE for the deliver-vs-buffer
17796
+ // routing when the cutover is on: the effects captured at the emit above
17797
+ // are executed through `dispatchEffects` (wired to real executors in
17798
+ // #3006) instead of the imperative twin below. The twin stays fully
17799
+ // intact as the kill-switch fallback (`SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0`
17800
+ // restores the exact legacy path) and is deleted in PR4 after the 48h bake.
17801
+ //
17802
+ // Two carve-outs fall back to the imperative twin even with the cutover on
17803
+ // (both documented in the RFC as un-modeled by the machine; the twin's
17804
+ // behavior is the contract until the machine grows the events):
17805
+ // 1. `!`-interrupt while the machine reads in_turn — the twin's
17806
+ // interrupt carve-out delivers (the SIGINT'd turn may never emit
17807
+ // turn_complete, so buffering would strand the body; see
17808
+ // decideInboundDelivery). The machine has no interrupt concept yet
17809
+ // and would buffer.
17810
+ // 2. machine reads bridge_dead — the twin attempts the send and, on
17811
+ // miss, applies the shouldTrackDelivery carve-outs (steering /
17812
+ // interrupt / sourced bodies are dropped, not replayed orphaned
17813
+ // after restart) + posts the restart notice. The machine's
17814
+ // unconditional buffer+persist would replay a steer as a fresh turn.
17815
+ const machineDelivers = machineInboundEffects.some((e) => e.kind === 'deliverToBridge')
17816
+ const machineBuffers = machineInboundEffects.some((e) => e.kind === 'bufferInbound')
17817
+ // ANCHORED STRING — must match the machine's bridge-dead inbound trace
17818
+ // stage verbatim (see the anchor comment at its emit site in
17819
+ // inbound-delivery-machine.ts + the pin test in
17820
+ // inbound-delivery-cutover-flip.test.ts). Deleted in PR4 with the twin.
17821
+ const machineBridgeDead = machineInboundEffects.some(
17822
+ (e) => e.kind === 'logTrace' && e.stage === 'inbound_bridge_dead_buffer',
17823
+ )
17824
+ const machineAuthoritative =
17825
+ isDeliveryCutoverEnabled() &&
17826
+ isDispatchEnabled() &&
17827
+ !machineBridgeDead &&
17828
+ (machineDelivers || (machineBuffers && interrupt.isInterrupt !== true))
17829
+ if (machineAuthoritative && machineBuffers) {
17830
+ // Machine says: mid-turn non-steering inbound → buffer until idle (the
17831
+ // #1556 contract). dispatchEffects executes bufferInbound (+ the
17832
+ // idempotent persistInbound — push() already spools when a spool is
17833
+ // attached) + the logTrace. The queued-status UX below is presentation,
17834
+ // not routing — it stays imperative on both paths until PR4.
17835
+ dispatchEffects(machineInboundEffects, {
17836
+ selfAgent,
17837
+ ipcServer,
17838
+ pendingInboundBuffer,
17839
+ inboundSpool: inboundSpool ?? null,
17840
+ pendingPermissionBuffer,
17841
+ })
17842
+ process.stderr.write(
17843
+ `telegram gateway: inbound held mid-turn (machine) agent=${selfAgent} ` +
17844
+ `chat=${chat_id} msg=${msgId ?? '-'} — will flush on turn-complete\n`,
17845
+ )
17846
+ const inFlightThreadM = currentTurn?.sessionThreadId
17847
+ const crossTopicQueuedCardM =
17848
+ QUEUED_STATUS_UX_ENABLED &&
17849
+ !isDmChatId(chat_id) &&
17850
+ messageThreadId != null &&
17851
+ messageThreadId !== inFlightThreadM
17852
+ if (crossTopicQueuedCardM) {
17853
+ postQueuedStatus(chat_id, messageThreadId, inFlightThreadM)
17854
+ } else {
17855
+ maybePostBusyAck('buffer-until-idle', chat_id, messageThreadId ?? undefined)
17856
+ }
17857
+ return
17858
+ }
17859
+
18062
17860
  // #2917: read the gate LIVE (not the receipt snapshot) on the default path
18063
17861
  // so a sibling same-chat inbound delivered during THIS handler's async
18064
17862
  // lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
@@ -18084,7 +17882,10 @@ async function handleInbound(
18084
17882
  // the freshly-killed bridge.
18085
17883
  isInterrupt: interrupt.isInterrupt,
18086
17884
  })
18087
- if (deliveryGate.decision === 'buffer-until-idle') {
17885
+ // PR3c: the legacy buffer branch runs ONLY when the machine is not
17886
+ // authoritative (kill switch / carve-out fallback) — the machine buffer
17887
+ // path above already returned for machine-routed buffering.
17888
+ if (!machineAuthoritative && deliveryGate.decision === 'buffer-until-idle') {
18088
17889
  pendingInboundBuffer.push(selfAgent, inboundMsg)
18089
17890
  process.stderr.write(
18090
17891
  `telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
@@ -18125,8 +17926,12 @@ async function handleInbound(
18125
17926
  // other's async lead-in and race to the bridge, reordering the replies.
18126
17927
  // Only fresh-turn deliveries reserve (steering/interrupt amend a running
18127
17928
  // turn and must not). Released below if the send misses (bridge offline).
17929
+ // PR3c: skipped when the machine is authoritative — the inbound event at
17930
+ // DEFERRED_INBOUND_EMIT advanced the machine SYNCHRONOUSLY (idle→in_turn),
17931
+ // so the machine itself is the pre-await reservation; the busy-key mirror
17932
+ // is stamped by the setTurnStarted effect in the dispatch below.
18128
17933
  let reservedBusyKey: string | null = null
18129
- if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
17934
+ if (!machineAuthoritative && deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
18130
17935
  reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
18131
17936
  }
18132
17937
 
@@ -18157,6 +17962,104 @@ async function handleInbound(
18157
17962
  }
18158
17963
  }
18159
17964
 
17965
+ // ── PR3c machine deliver path (#2794) ──────────────────────────────────
17966
+ // The machine said deliver (fresh turn: setTurnStarted + deliverToBridge;
17967
+ // steer: deliverToBridge only). dispatchEffects executes the send; the
17968
+ // post-send branches below mirror the imperative twin bit-for-bit:
17969
+ // delivered → steer ack + busy-mark (idempotent, same lifecycle the twin
17970
+ // stamps) + delivery-confirm tracking; miss → release the busy mirror in
17971
+ // lockstep, durable-buffer per the shouldTrackDelivery carve-outs, and
17972
+ // post the restart notice. NOTE the machine already advanced to in_turn
17973
+ // at the emit; a send-miss therefore leaves the machine in_turn until the
17974
+ // TTL tick clears it — identical to today's cutover behavior (the emit
17975
+ // has always preceded the send attempt), not a regression of this flip.
17976
+ if (machineAuthoritative) {
17977
+ let machineDelivered = false
17978
+ // Key stamped by THIS dispatch's setTurnStarted effect (fresh turn only —
17979
+ // a mid-turn steer emits deliverToBridge WITHOUT setTurnStarted). The
17980
+ // miss branch below may only release a key THIS dispatch stamped: on a
17981
+ // steer-miss the chat's busy key belongs to the ORIGINAL in-flight turn
17982
+ // (mirroring the twin, which only ever releases reservedBusyKey and never
17983
+ // reserves for steers). An unconditional delete would wipe the running
17984
+ // turn's key → false machine_over_holds parity drift during the bake +
17985
+ // the pending-restart gate (claudeBusyKeys.size) could green-light a
17986
+ // mid-turn restart.
17987
+ let machineStampedKey: string | null = null
17988
+ dispatchEffects(machineInboundEffects, {
17989
+ selfAgent,
17990
+ ipcServer,
17991
+ pendingInboundBuffer,
17992
+ inboundSpool: inboundSpool ?? null,
17993
+ pendingPermissionBuffer,
17994
+ // Busy-key mirror: same lockstep add markClaudeBusyForInbound does —
17995
+ // keeps the kill-switch fallback + gate-parity-probe + pending-restart
17996
+ // gate (claudeBusyKeys.size) coherent while the twins remain live.
17997
+ onSetTurnStarted: (k, at) => {
17998
+ machineStampedKey = k
17999
+ markBusyKeyLockstep(claudeBusyKeys, claudeBusyKeySince, k, at)
18000
+ },
18001
+ onDeliverResult: (_k, ok) => {
18002
+ machineDelivered = ok
18003
+ },
18004
+ })
18005
+ if (machineDelivered) {
18006
+ if (isSteering) {
18007
+ maybePostBusyAck('steer', chat_id, messageThreadId ?? undefined)
18008
+ }
18009
+ // Mirror the twin: EVERY delivered inbound stamps the busy key
18010
+ // (steer/interrupt included — idempotent lockstep re-stamp), and the
18011
+ // returned key is what the delivery-confirm tracker matches acks on.
18012
+ const busyKey = markClaudeBusyForInbound(inboundMsg)
18013
+ if (
18014
+ DELIVERY_CONFIRM_ENABLED &&
18015
+ shouldTrackDelivery({
18016
+ isSteering,
18017
+ isInterrupt: interrupt.isInterrupt,
18018
+ hasSource: inboundMsg.meta?.source != null,
18019
+ effectiveText,
18020
+ })
18021
+ ) {
18022
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
18023
+ }
18024
+ } else {
18025
+ // Send missed while the machine read bridge-alive. Release ONLY a busy
18026
+ // mirror THIS dispatch's setTurnStarted stamped (lockstep with the
18027
+ // twin's reservedBusyKey release). A steer-miss stamped nothing — the
18028
+ // chat key belongs to the original in-flight turn and must survive
18029
+ // (see machineStampedKey above). Then buffer per the carve-outs, notify.
18030
+ //
18031
+ // Known behavior delta (documented, accepted for the bake): the
18032
+ // machine already advanced to in_turn at the emit, so until the TTL
18033
+ // tick / bridge-flap clears it, SUBSEQUENT inbounds are machine-
18034
+ // buffered ("queued" busy-ack) instead of the legacy per-message send
18035
+ // attempt + restart notice. See the RFC carve-out row + PR body.
18036
+ if (machineStampedKey != null) {
18037
+ claudeBusyKeys.delete(machineStampedKey)
18038
+ claudeBusyKeySince.delete(machineStampedKey)
18039
+ }
18040
+ if (
18041
+ shouldTrackDelivery({
18042
+ isSteering,
18043
+ isInterrupt: interrupt.isInterrupt,
18044
+ hasSource: inboundMsg.meta?.source != null,
18045
+ effectiveText,
18046
+ })
18047
+ ) {
18048
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
18049
+ }
18050
+ const threadOptsM = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
18051
+ void swallowingApiCall(
18052
+ () => bot.api.sendMessage(chat_id, '⏳ Agent is restarting — your message is queued and will be processed when it reconnects.', { ...threadOptsM }),
18053
+ {
18054
+ chat_id,
18055
+ verb: 'agent-restarting-notice',
18056
+ ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
18057
+ },
18058
+ )
18059
+ }
18060
+ return
18061
+ }
18062
+
18160
18063
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
18161
18064
  if (delivered) {
18162
18065
  // #2995 — a steer delivered mid-turn while the turn is stuck inside one
@@ -22123,2233 +22026,100 @@ function resolveAgentDirForName(agent: string): string | null {
22123
22026
  return null
22124
22027
  }
22125
22028
 
22126
- /**
22127
- * Handle a callback_query from an auth dashboard button. Parses the
22128
- * callback_data, runs the matching action, acknowledges the tap with a
22129
- * toast, and refreshes the dashboard in-place via editMessageText.
22130
- */
22131
- /**
22132
- * Handle op:<action>:<encoded-agent> callbacks from operator-events.ts
22133
- * renderOperatorEvent(). Phase 4b — closes the "buttons do nothing" gap.
22134
- *
22135
- * Actions:
22136
- * dismiss — clear keyboard + toast
22137
- * restart — systemctl --user restart switchroom-<agent>
22138
- * reauth — delegate to runSwitchroomAuthCommand (same flow as /auth reauth)
22139
- * logs — post last 30 lines of journalctl for the agent
22140
- * slot management buttons — removed (E5); use /auth use or /auth add instead.
22141
- */
22142
- /**
22143
- * Issue #44: handle taps on the deferred-secret card's inline buttons.
22144
- *
22145
- * `vd:unlock:<deferKey>` — register a `passphrase-for-deferred` pending
22146
- * vault op and edit the card to ask the user for their passphrase.
22147
- * The text-handler picks the passphrase up via the existing
22148
- * pendingVaultOps intercept and calls `executeDeferredSecretSave`
22149
- * to write the held secret directly. No re-paste required.
22150
- *
22151
- * `vd:cancel:<deferKey>` — drop the deferred secret and clear the card.
22152
- * The held bytes are evicted from the in-memory `deferredSecrets`
22153
- * map (they were never written to disk) so the secret vanishes.
22154
- *
22155
- * Authorization mirrors the operator-event callback: only senders on the
22156
- * configured allowlist get to act on the buttons.
22157
- */
22158
- /**
22159
- * Issue #969 P1a — handle the agent-initiated vault-save approval card
22160
- * (`vault_request_save` MCP tool).
22161
- *
22162
- * Callbacks:
22163
- * vrs:save:<stageId> confirm save; write to vault using broker put
22164
- * with operator-passphrase attestation (#969 P1a)
22165
- * so even new keys go through in one tap.
22166
- * vrs:discard:<stageId> — drop the staged secret; never touches disk.
22167
- * vrs:rename:<stageId> — set up a pending-op intercept so the user's
22168
- * next message is taken as a new key name.
22169
- */
22170
- /**
22171
- * Issue #969 P2b — handle a tap on the "🔓 Allow <key>" button posted by
22172
- * `/vault audit <agent>`'s Recent denials section. Mints a 30-day
22173
- * read-grant for the agent + key via the broker.
22174
- *
22175
- * The grant also unioning into the agent's existing token if one is
22176
- * already present is out of scope for this PR — the operator can
22177
- * re-mint with a wider key list if they want consolidation.
22178
- */
22179
- async function handleVaultRecentDenialCallback(ctx: Context, data: string): Promise<void> {
22180
- const senderId = String(ctx.from?.id ?? '')
22181
- const access = loadAccess()
22182
- if (!access.allowFrom.includes(senderId)) {
22183
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22184
- return
22185
- }
22186
- // vrd:<agent>:<key> — parse, validate both halves against the strict
22187
- // slug regex before doing anything else.
22188
- const parts = data.split(':')
22189
- if (parts.length !== 3) {
22190
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
22191
- return
22192
- }
22193
- const [, agentName, keyName] = parts
22194
- if (!/^[a-z][a-z0-9-]{0,62}$/i.test(agentName)) {
22195
- await ctx.answerCallbackQuery({ text: 'Invalid agent name' }).catch(() => {})
22196
- return
22197
- }
22198
- // #1047: same canonical key shape as vault_request_save /
22199
- // vault_request_access — namespaced keys like `fatsecret/client_id`
22200
- // must round-trip through the /vault audit one-tap Allow flow too,
22201
- // not just the agent-initiated approval cards.
22202
- if (!VAULT_KEY_REGEX.test(keyName)) {
22203
- await ctx.answerCallbackQuery({ text: 'Invalid key name' }).catch(() => {})
22204
- return
22205
- }
22206
- await ctx.answerCallbackQuery({ text: '⏳ Minting 30-day read grant…' }).catch(() => {})
22029
+ // ─── Callback-query handler families (#2996 Phase 5, remaining item 2) ─────
22030
+ // Extracted verbatim to callback-query-handlers.ts behind an injected deps
22031
+ // object (see that module's header for what moved and what deliberately
22032
+ // stayed). Destructuring keeps every call site below byte-identical.
22033
+ const callbackQueryHandlers = createCallbackQueryHandlers({
22034
+ bot,
22035
+ lockedBot,
22036
+ loadAccess,
22037
+ escapeHtmlForTg,
22038
+ switchroomReply,
22039
+ resolveThreadId,
22040
+ deliverResumeSyntheticOrBuffer,
22041
+ expireMentalModelProposeCard,
22042
+ readLiveSwitchroomConfigText,
22043
+ mentalModelCorrelationKey,
22044
+ getMyAgentName,
22045
+ triggerSelfRestart,
22046
+ runSwitchroomAuthCommand,
22047
+ switchroomExecJson,
22048
+ assertSafeAgentName,
22049
+ buildDeferredSecretKeyboard,
22050
+ recordDeferredSecretKernelDecision,
22051
+ mintGrantWizardKernelRequest,
22052
+ recordGrantWizardKernelDecision,
22053
+ robustApiCall,
22054
+ swallowingApiCall,
22055
+ pendingVaultRequestAccesses,
22056
+ pendingVaultRequestSaves,
22057
+ pendingMentalModelProposes,
22058
+ pendingCardStore,
22059
+ pendingMentalModelCorrelations,
22060
+ pendingVaultOps,
22061
+ vaultPassphraseCache,
22062
+ deferredSecrets,
22063
+ pendingReauthFlows,
22064
+ secretStaging,
22065
+ lastAuthRefreshAtMs,
22066
+ // Mutable module `let`s injected as getters so the startup config
22067
+ // assignment (and any later reload) stays observable from the module.
22068
+ getVaultApprovalAuthMode: () => VAULT_APPROVAL_AUTH_MODE,
22069
+ getAdminOnlyKeys: () => ADMIN_ONLY_KEYS,
22070
+ vaultKeyRegex: VAULT_KEY_REGEX,
22071
+ mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
22072
+ })
22073
+ const {
22074
+ handleVaultRecentDenialCallback,
22075
+ performVaultAccessApproval,
22076
+ handleSkillProposalCallback,
22077
+ handleMentalModelProposeCallback,
22078
+ handleVaultRequestAccessCallback,
22079
+ handleVaultRequestSaveCallback,
22080
+ handleVaultDeferCallback,
22081
+ parseGrantDuration,
22082
+ startGrantWizardStep1,
22083
+ grantWizardConfirm,
22084
+ handleVaultGrantCallback,
22085
+ executeDeferredSecretSave,
22086
+ handleOperatorEventCallback,
22087
+ handleAuthDashboardCallback,
22088
+ } = callbackQueryHandlers
22207
22089
 
22208
- const result = await mintGrantViaBroker({
22209
- agent: agentName,
22210
- keys: [keyName],
22211
- ttl_seconds: 30 * 24 * 60 * 60,
22212
- description: `auto-mint via /vault audit one-tap (#969 P2b)`,
22213
- })
22090
+ // /reauth was removed in v0.6.13 — the `/auth` dashboard's
22091
+ // `🔄 Reauth default` button fires the same flow (the `case 'reauth':`
22092
+ // callback dispatch calls `runSwitchroomAuthCommand` and seeds
22093
+ // `pendingReauthFlows`). The OAuth code paste-back is caught by the
22094
+ // generic message intercept that watches `pendingReauthFlows`
22095
+ // pasting the code into chat now Just Works without a typed entry
22096
+ // point. Removed surfaces in this PR:
22097
+ // - bot.command('reauth', ...) → use /auth → 🔄 Reauth
22098
+ // - /reauth <code|url> paste-back → paste into chat
22099
+ // - /reauth <other-agent> targeting → use that agent's /auth
22214
22100
 
22215
- if (result.kind === 'unreachable') {
22216
- await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
22217
- return
22218
- }
22219
- if (result.kind === 'error') {
22220
- await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
22221
- return
22222
- }
22223
- // Write the token to the agent's .vault-token file — same flow as the
22224
- // vault grant wizard. The agent restarts in the background pick up
22225
- // the new token via SWITCHROOM_AGENT_NAME on next CLI invocation.
22226
- const { token, id } = result
22227
- const tokenPath = join(homedir(), '.switchroom', 'agents', agentName, '.vault-token')
22228
- try {
22229
- mkdirSync(join(homedir(), '.switchroom', 'agents', agentName), { recursive: true })
22230
- writeFileSync(tokenPath, token, { mode: 0o600 })
22231
- } catch (err) {
22232
- await switchroomReply(
22233
- ctx,
22234
- `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
22235
- `${escapeHtmlForTg(String(err))}\n` +
22236
- `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
22237
- `--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
22238
- { html: true },
22239
- )
22240
- return
22241
- }
22242
- // #1150 audit: P0 fix — pre-fix the audit-listing message kept its
22243
- // tappable [Always allow ...] buttons after a successful mint, so
22244
- // the operator could re-tap the same denial and re-mint the grant
22245
- // (broker idempotency saves us from a duplicate write but the
22246
- // operator experience was "did anything happen? let me tap again").
22247
- // Strip the entire audit-listing keyboard on first tap + append a
22248
- // status line so the action is visible. Operator re-runs `/vault
22249
- // audit` to act on remaining denials — that's the documented flow.
22250
- // HTML-escape the source text before concatenation. `ctx.callbackQuery.
22251
- // message.text` returns the body with entities STRIPPED (Telegram
22252
- // decodes the original HTML), so any raw `<`, `>`, `&` in agent/key
22253
- // names that survived the original audit-listing's escape pass would
22254
- // now break the HTML re-parse — and finalizeCallback's catch swallows
22255
- // the failure, leaving the keyboard tappable. Caught in PR #1158 review
22256
- // for the operator-event card; the same fix applies here.
22257
- const sourceMsg = ctx.callbackQuery?.message
22258
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
22259
- ? escapeHtmlForTg(sourceMsg.text)
22260
- : ''
22261
- const statusLine =
22262
- `\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
22263
- `\`${keyName}\` for 30 days ` +
22264
- `(grant \`${id}\`). ` +
22265
- `Re-run /vault audit to act on remaining denials.`
22266
- await finalizeCallback(ctx, {
22267
- ackText: '✅ Grant minted',
22268
- newText: baseText ? `${baseText}${statusLine}` : statusLine,
22269
- // No synthInbound — operator-only flow. The granted agent picks
22270
- // up the token via .vault-token file on next CLI invocation; no
22271
- // turn-wake needed.
22272
- })
22273
- }
22274
-
22275
- /**
22276
- * Issue #1012 — handle a tap on the vault_request_access approval card.
22277
- * vra:approve:<stageId> — mint a scoped grant token via the broker,
22278
- * write the token to the agent's
22279
- * `.vault-token` file, edit card to success.
22280
- * vra:deny:<stageId> — drop the staged request, edit card to denied.
22281
- *
22282
- * Same authorization gate as the recent-denials one-tap handler:
22283
- * sender must be on the gateway's allowFrom list.
22284
- */
22285
- /**
22286
- * Mint the scoped grant + write the token file for an approved
22287
- * `vault_request_access` request. Factored out so both the direct
22288
- * approve-tap (passphrase already cached) and the
22289
- * `passphrase-for-access-approve` resume flow (passphrase captured
22290
- * via text-message intercept after tap-on-locked) drive identical
22291
- * minting behaviour. #1012 Phase 2 + follow-up.
22292
- */
22293
- /**
22294
- * #1115 follow-up: caller-supplied attestation. Either a real operator
22295
- * passphrase (when the operator typed it in chat) or a posture flag
22296
- * that tells the broker to use its own retained passphrase under
22297
- * `vault.broker.approvalAuth: telegram-id`. The passphrase variant
22298
- * never crosses into telegram-id callsites; the posture variant
22299
- * never crosses into passphrase-mode callsites.
22300
- */
22301
- type AccessApprovalAttestation =
22302
- | { kind: 'passphrase'; passphrase: string }
22303
- | { kind: 'posture' }
22304
-
22305
- async function performVaultAccessApproval(
22306
- ctx: Context,
22307
- pending: PendingVaultRequestAccess,
22308
- stageId: string,
22309
- senderId: string,
22310
- attestation: AccessApprovalAttestation,
22311
- ): Promise<void> {
22312
- const brokerAuthOpts =
22313
- attestation.kind === 'passphrase'
22314
- ? { passphrase: attestation.passphrase }
22315
- : { attest_via_posture: true as const }
22316
-
22317
- // Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
22318
- // card staged before the key became standing-ACL-covered (config edit
22319
- // / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
22320
- // this read key, do NOT mint — minting writes a `.vault-token` that
22321
- // shadows the standing ACL and is redundant. Authoritative broker
22322
- // probe AS THIS AGENT (no-token list over the per-agent socket — same
22323
- // rationale as executeVaultRequestAccess; never a gateway-side config
22324
- // read). Read scope only. Fail-open on probe error (mint as before).
22325
- if (pending.scope === 'read') {
22326
- try {
22327
- const visible = await listViaBroker()
22328
- if (visible !== null && visible.includes(pending.key)) {
22329
- pendingVaultRequestAccesses.delete(stageId)
22330
- pendingCardStore.remove(stageId)
22331
- if (pending.card_message_id != null) {
22332
- await ctx.api
22333
- .editMessageText(
22334
- pending.chat_id,
22335
- pending.card_message_id,
22336
- `ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
22337
- `\`${pending.key}\` (schedule.secrets[]). ` +
22338
- `**No grant minted** — a token would shadow the standing ACL. ` +
22339
- richMessage(`The agent can read it directly.`),
22340
- { reply_markup: { inline_keyboard: [] } },
22341
- )
22342
- .catch(() => {})
22343
- }
22344
- return
22345
- }
22346
- } catch {
22347
- // Probe failed: fall through and mint as before (fail-open).
22348
- }
22349
- }
22350
-
22351
- // #1051: union the new key with the agent's existing active grant
22352
- // before minting. Without this, each fresh Approve OVERWRITES the
22353
- // agent's `.vault-token` file with a single-key grant — the
22354
- // previous approval's grant is still in the broker DB but the
22355
- // agent can no longer authenticate against it (the CLI reads the
22356
- // file's current token, the broker validates it, sees the new key
22357
- // isn't in the OLD grant's key_allow, and denies).
22358
- //
22359
- // Solution: list the agent's existing non-expired grants
22360
- // (passphrase-attested per #1051's broker-side gate widening),
22361
- // find the active read-grant (most recent non-revoked,
22362
- // non-expired), and pass its keys ∪ new_key as `keys` to the
22363
- // mint call. Old grant ages out via TTL — no explicit revoke
22364
- // needed.
22365
- let existingReadKeys: string[] = [];
22366
- let existingWriteKeys: string[] = [];
22367
- if (pending.scope === 'read' || pending.scope === 'write') {
22368
- const list = await listGrantsViaBroker(pending.agent, brokerAuthOpts);
22369
- if (list.kind === 'ok') {
22370
- const now = Math.floor(Date.now() / 1000);
22371
- // Prefer the MOST RECENT non-revoked, non-expired grant. The
22372
- // broker's listGrants returns ALL non-revoked, but we still
22373
- // filter expires_at locally as defence-in-depth + sort by
22374
- // created_at desc for stability.
22375
- const active = list.grants
22376
- .filter((g) => g.expires_at === null || g.expires_at > now)
22377
- // Reviewer-flagged on #1058 (Q4): `created_at` is
22378
- // seconds-granularity, so two grants minted in the same
22379
- // wall-clock second tie. Secondary sort by `id` (vg_<6hex>)
22380
- // makes the ordering stable so item 2's drain reliably picks
22381
- // up item 1's just-minted grant rather than an unrelated
22382
- // same-second grant.
22383
- .sort((a, b) => {
22384
- const dt = (b.created_at ?? 0) - (a.created_at ?? 0);
22385
- if (dt !== 0) return dt;
22386
- return b.id.localeCompare(a.id);
22387
- });
22388
- if (active.length > 0) {
22389
- existingReadKeys = active[0]!.key_allow ?? [];
22390
- existingWriteKeys = active[0]!.write_allow ?? [];
22391
- }
22392
- }
22393
- // If list fails (broker unreachable / error), proceed without
22394
- // union — better to mint a single-key grant than fail closed
22395
- // entirely. The agent loses the prior coverage in that edge
22396
- // case, same as today, but the new key is granted.
22397
- }
22398
-
22399
- // Compute the unioned key sets. Use Set to dedupe.
22400
- const readKeys = new Set<string>(existingReadKeys);
22401
- const writeKeys = new Set<string>(existingWriteKeys);
22402
- if (pending.scope === 'read') readKeys.add(pending.key);
22403
- if (pending.scope === 'write') writeKeys.add(pending.key);
22404
-
22405
- const mintArgs: Parameters<typeof mintGrantViaBroker>[0] = {
22406
- agent: pending.agent,
22407
- keys: Array.from(readKeys),
22408
- ttl_seconds: pending.ttl_seconds,
22409
- description:
22410
- `auto-mint via vault_request_access (#1012, scope=${pending.scope}, by op ${senderId}` +
22411
- (existingReadKeys.length + existingWriteKeys.length > 0
22412
- ? `, unioned with prior grant`
22413
- : ``) +
22414
- `)`,
22415
- ...(writeKeys.size > 0 ? { write_keys: Array.from(writeKeys) } : {}),
22416
- ...brokerAuthOpts,
22417
- }
22418
- const result = await mintGrantViaBroker(mintArgs)
22419
- if (result.kind === 'unreachable') {
22420
- await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
22421
- return
22422
- }
22423
- if (result.kind === 'error') {
22424
- // Mint refused (most likely wrong passphrase). Drop the staged
22425
- // request so a re-attempt starts cleanly. The operator can ask
22426
- // the agent to re-issue, or the broker error message will tell
22427
- // them the next step.
22428
- pendingVaultRequestAccesses.delete(stageId)
22429
- pendingCardStore.remove(stageId)
22430
- if (pending.card_message_id != null) {
22431
- await ctx.api
22432
- .editMessageText(
22433
- pending.chat_id,
22434
- pending.card_message_id,
22435
- richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
22436
- { reply_markup: { inline_keyboard: [] } },
22437
- )
22438
- .catch(() => {})
22439
- }
22440
- return
22441
- }
22442
-
22443
- const { token, id } = result
22444
- const tokenPath = join(homedir(), '.switchroom', 'agents', pending.agent, '.vault-token')
22445
- try {
22446
- mkdirSync(join(homedir(), '.switchroom', 'agents', pending.agent), { recursive: true })
22447
- writeFileSync(tokenPath, token, { mode: 0o600 })
22448
- } catch (err) {
22449
- await switchroomReply(
22450
- ctx,
22451
- `**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
22452
- `${escapeHtmlForTg(String(err))}\n` +
22453
- `_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
22454
- `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
22455
- { html: true },
22456
- )
22457
- return
22458
- }
22459
-
22460
- pendingVaultRequestAccesses.delete(stageId)
22461
- pendingCardStore.remove(stageId)
22462
- if (pending.card_message_id != null) {
22463
- const days = Math.round(pending.ttl_seconds / 86400)
22464
- const footer =
22465
- VAULT_APPROVAL_AUTH_MODE === 'telegram-id'
22466
- ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
22467
- : ''
22468
- await ctx.api
22469
- .editMessageText(
22470
- pending.chat_id,
22471
- pending.card_message_id,
22472
- richMessage(
22473
- buildVaultGrantApprovedCardText({
22474
- agentEscaped: escapeHtmlForTg(pending.agent),
22475
- scope: pending.scope,
22476
- key: pending.key,
22477
- days,
22478
- grantId: id,
22479
- footer,
22480
- }),
22481
- ),
22482
- { reply_markup: { inline_keyboard: [] } },
22483
- )
22484
- .catch(() => {})
22485
- }
22486
-
22487
- // #1052: deliver a synthetic inbound message back to the agent so
22488
- // the task that fired vault_request_access auto-resumes — without
22489
- // this, the agent's turn ended after the tool call ("waiting for
22490
- // approval") and the operator has to send a fresh message to kick
22491
- // it back into action.
22492
- //
22493
- // Uses the existing inject_inbound primitive (cron's pattern from
22494
- // dispatch.ts:180-206). The bridge sees a normal channel event,
22495
- // renders it as `<channel source="vault_grant_approved">`, and the
22496
- // agent starts a new turn with the context that the operator just
22497
- // approved.
22498
- //
22499
- // The synthetic message text is concise + actionable so the agent
22500
- // knows (a) which key was approved, (b) at what scope, (c) what to
22501
- // do next. Meta carries the structured fields for forensics + for
22502
- // future filters that want to suppress these in the chat tail.
22503
- const synthetic = buildVaultGrantApprovedInbound({
22504
- ctx: {
22505
- agent: pending.agent,
22506
- key: pending.key,
22507
- scope: pending.scope,
22508
- chat_id: pending.chat_id,
22509
- ttl_seconds: pending.ttl_seconds,
22510
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
22511
- },
22512
- grantId: id,
22513
- stageId,
22514
- operatorId: senderId,
22515
- })
22516
- // Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
22517
- // (flushed at turn-end) so the resume never strands in claude's
22518
- // composer (#1556); idle → deliver; bridge-down → buffer (#1150).
22519
- const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
22520
- process.stderr.write(
22521
- `telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
22522
- `key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
22523
- )
22524
- }
22525
-
22526
- /**
22527
- * #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
22528
- * proposal card.
22529
- *
22530
- * Approve → mark the proposal approved + inject a synthetic
22531
- * `skill_proposal_apply` turn instructing the live agent to
22532
- * write the stored draft through `skill_*_personal` (so the
22533
- * secret-scan pipeline runs; agent never self-applies).
22534
- * Dismiss → mark rejected + write a rejection fingerprint so the weekly
22535
- * synthesis cron doesn't re-propose it.
22536
- */
22537
- async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
22538
- const senderId = String(ctx.from?.id ?? '')
22539
- const access = loadAccess()
22540
- if (!access.allowFrom.includes(senderId)) {
22541
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22542
- return
22543
- }
22544
- const parsed = parseSkillProposalCallback(data)
22545
- if (parsed == null) {
22546
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
22547
- return
22548
- }
22549
- const stateDir = process.env.TELEGRAM_STATE_DIR
22550
- const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
22551
- if (stateDir == null || stateDir.length === 0) {
22552
- await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
22553
- return
22554
- }
22555
- const proposal = getSkillProposal(stateDir, parsed.id)
22556
- if (proposal == null) {
22557
- await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
22558
- if (ctx.callbackQuery?.message) {
22559
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
22560
- }
22561
- return
22562
- }
22563
- if (proposal.status !== 'pending') {
22564
- await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
22565
- if (ctx.callbackQuery?.message) {
22566
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
22567
- }
22568
- return
22569
- }
22570
-
22571
- const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
22572
- const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
22573
-
22574
- if (parsed.action === 'deny') {
22575
- setSkillProposalStatus(stateDir, parsed.id, 'rejected')
22576
- await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
22577
- if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
22578
- await ctx
22579
- .editMessageText(
22580
- `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
22581
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
22582
- )
22583
- .catch(() => {})
22584
- }
22585
- return
22586
- }
22587
-
22588
- // Approve.
22589
- setSkillProposalStatus(stateDir, parsed.id, 'approved')
22590
- const synthetic = buildSkillProposalApplyInbound({
22591
- ctx: {
22592
- agent,
22593
- chat_id: cbChatId,
22594
- ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
22595
- },
22596
- proposalId: proposal.id,
22597
- skillSlug: proposal.skill_slug,
22598
- isNew: proposal.is_new,
22599
- operatorId: senderId,
22600
- })
22601
- const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
22602
- await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
22603
- if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
22604
- await ctx
22605
- .editMessageText(
22606
- `${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
22607
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
22608
- )
22609
- .catch(() => {})
22610
- }
22611
- process.stderr.write(
22612
- `telegram gateway: skill_proposal_apply injection agent=${agent} ` +
22613
- `proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
22614
- )
22615
- }
22616
-
22617
- /**
22618
- * hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
22619
- * mmp:approve:<stageId> — declare the model: append it to the agent's
22620
- * memory.mental_models[] via the operator-approved
22621
- * config-edit path (reused config_propose_edit
22622
- * apply+reconcile; reconcile ensures it), then wake
22623
- * the agent with an "applied" inbound.
22624
- * mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
22625
- * agent with a "denied" inbound.
22626
- *
22627
- * Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
22628
- * can PROPOSE but can never self-approve (identical gate to the vault flow).
22629
- */
22630
- async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
22631
- const senderId = String(ctx.from?.id ?? '')
22632
- const access = loadAccess()
22633
- if (!access.allowFrom.includes(senderId)) {
22634
- // Self-approve is impossible: only an allow-listed operator can resolve
22635
- // the card. A tap from anyone else (incl. a compromised agent identity) is
22636
- // refused here.
22637
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22638
- return
22639
- }
22640
- const parts = data.split(':')
22641
- if (parts.length < 3) {
22642
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
22643
- return
22644
- }
22645
- const action = parts[1]
22646
- const stageId = parts.slice(2).join(':')
22647
- const pending = pendingMentalModelProposes.get(stageId)
22648
- if (!pending) {
22649
- await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
22650
- if (ctx.callbackQuery?.message) {
22651
- await ctx.api
22652
- .editMessageText(
22653
- ctx.callbackQuery.message.chat.id,
22654
- ctx.callbackQuery.message.message_id,
22655
- richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
22656
- { reply_markup: { inline_keyboard: [] } },
22657
- )
22658
- .catch(() => {})
22659
- }
22660
- return
22661
- }
22662
- if (action !== 'approve' && action !== 'deny') {
22663
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
22664
- return
22665
- }
22666
- // Enforce the TTL at TAP time, not just on the next propose's sweep. Without
22667
- // this, a card left untapped past its TTL is still resolvable if no fresh
22668
- // proposal has run the sweep — an operator could approve a stale proposal.
22669
- if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
22670
- // Expired between post and tap: route through the shared expiry path so the
22671
- // parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
22672
- // the durable store entry is cleared — not just a silent map delete.
22673
- expireMentalModelProposeCard(stageId, pending, Date.now())
22674
- await ctx.answerCallbackQuery({ text: 'Card expired — the agent was notified.' }).catch(() => {})
22675
- return
22676
- }
22677
- // Single-shot: remove the pending entry immediately so a double-tap can't
22678
- // resolve twice.
22679
- pendingMentalModelProposes.delete(stageId)
22680
- pendingCardStore.remove(stageId)
22681
-
22682
- const proposal: MentalModelPendingProposal = {
22683
- agent: pending.agent,
22684
- chat_id: pending.chat_id,
22685
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
22686
- spec: pending.spec,
22687
- ...(pending.reason ? { reason: pending.reason } : {}),
22688
- }
22689
-
22690
- const resolveDeps = {
22691
- readConfigText: () => readLiveSwitchroomConfigText(),
22692
- registerPreApproval: (agent: string, diff: string) => {
22693
- pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
22694
- agentName: agent,
22695
- unifiedDiff: diff,
22696
- createdAt: Date.now(),
22697
- })
22698
- },
22699
- clearPreApproval: (agent: string, diff: string) => {
22700
- pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
22701
- },
22702
- dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
22703
- const req: HostdRequest = {
22704
- v: 1,
22705
- op: 'config_propose_edit',
22706
- request_id: hostdRequestId('gw-mental-model'),
22707
- args: {
22708
- unified_diff: a.diff,
22709
- reason: a.reason,
22710
- target_path: '/state/config/switchroom.yaml',
22711
- },
22712
- }
22713
- // config_propose_edit blocks on validate→approve→apply→reconcile
22714
- // (5-10 min on a busy host) — allow 12 min. The operator already
22715
- // approved on the proposal card, so hostd's config-approval callback
22716
- // auto-resolves via the pre-registered correlation (no second card).
22717
- const resp = await tryHostdDispatch(a.agent, req, 720_000)
22718
- if (resp === 'not-configured') {
22719
- return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
22720
- }
22721
- if (resp.result === 'completed') return { state: 'applied' as const }
22722
- if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
22723
- return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
22724
- },
22725
- // Ensure is delegated to reconcile: config_propose_edit's apply triggers a
22726
- // reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
22727
- // declared model — the authoritative, correctly-scoped ensure. We
22728
- // deliberately do NOT add a redundant gateway-side ensure (it would need
22729
- // the agent's bank id + a reachable Hindsight endpoint from the gateway).
22730
- injectInbound: (inbound: InboundMessage) => {
22731
- deliverResumeSyntheticOrBuffer(pending.agent, inbound)
22732
- },
22733
- log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
22734
- }
22735
-
22736
- if (action === 'deny') {
22737
- await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
22738
- await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
22739
- if (pending.card_message_id != null) {
22740
- await ctx.api
22741
- .editMessageText(
22742
- pending.chat_id,
22743
- pending.card_message_id,
22744
- richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
22745
- { reply_markup: { inline_keyboard: [] } },
22746
- )
22747
- .catch(() => {})
22748
- }
22749
- return
22750
- }
22751
-
22752
- // Approve. Ack immediately + show an interim state, then persist in the
22753
- // background (config_propose_edit can take minutes), then edit the card with
22754
- // the real outcome. The turn resumes via the synthetic inbound injected by
22755
- // resolveMentalModelProposal — not by this card edit.
22756
- await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
22757
- if (pending.card_message_id != null) {
22758
- await ctx.api
22759
- .editMessageText(
22760
- pending.chat_id,
22761
- pending.card_message_id,
22762
- richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
22763
- { reply_markup: { inline_keyboard: [] } },
22764
- )
22765
- .catch(() => {})
22766
- }
22767
- void (async () => {
22768
- let result
22769
- try {
22770
- result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
22771
- } catch (err) {
22772
- process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
22773
- result = { outcome: 'failed' as const, reason: (err as Error).message }
22774
- }
22775
- if (pending.card_message_id != null) {
22776
- const label =
22777
- result.outcome === 'applied'
22778
- ? `✅ **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.`
22779
- : `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
22780
- await ctx.api
22781
- .editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
22782
- reply_markup: { inline_keyboard: [] },
22783
- link_preview_options: { is_disabled: true },
22784
- })
22785
- .catch(() => {})
22786
- }
22787
- })()
22788
- }
22789
-
22790
- async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
22791
- const senderId = String(ctx.from?.id ?? '')
22792
- const access = loadAccess()
22793
- if (!access.allowFrom.includes(senderId)) {
22794
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
22795
- return
22796
- }
22797
- const parts = data.split(':')
22798
- if (parts.length < 3) {
22799
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
22800
- return
22801
- }
22802
- const action = parts[1]
22803
- const stageId = parts.slice(2).join(':')
22804
- const pending = pendingVaultRequestAccesses.get(stageId)
22805
- if (!pending) {
22806
- await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-request.' }).catch(() => {})
22807
- if (ctx.callbackQuery?.message) {
22808
- await ctx.api
22809
- .editMessageText(
22810
- ctx.callbackQuery.message.chat.id,
22811
- ctx.callbackQuery.message.message_id,
22812
- richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
22813
- { reply_markup: { inline_keyboard: [] } },
22814
- )
22815
- .catch(() => {})
22816
- }
22817
- return
22818
- }
22819
-
22820
- if (action === 'deny') {
22821
- pendingVaultRequestAccesses.delete(stageId)
22822
- pendingCardStore.remove(stageId)
22823
- await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
22824
- if (pending.card_message_id != null) {
22825
- await ctx.api
22826
- .editMessageText(
22827
- pending.chat_id,
22828
- pending.card_message_id,
22829
- richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
22830
- { reply_markup: { inline_keyboard: [] } },
22831
- )
22832
- .catch(() => {})
22833
- }
22834
- // #1150 sibling: invariant-3 was missing on the deny path too. The
22835
- // agent originally ended its turn after `vault_request_access` and
22836
- // waits for the gateway to wake it. On approve we already inject
22837
- // `vault_grant_approved` (#1052); now we mirror that for deny so
22838
- // the agent can pick the fallback path (apologise to the user,
22839
- // try a different approach, skip the feature) instead of staying
22840
- // wedged forever. Buffer-on-failure so a mid-reconnect bridge
22841
- // still receives this on its next register.
22842
- const denyInbound = buildVaultGrantDeniedInbound({
22843
- ctx: {
22844
- agent: pending.agent,
22845
- key: pending.key,
22846
- scope: pending.scope,
22847
- chat_id: pending.chat_id,
22848
- ttl_seconds: pending.ttl_seconds,
22849
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
22850
- },
22851
- stageId,
22852
- operatorId: senderId,
22853
- })
22854
- const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
22855
- process.stderr.write(
22856
- `telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
22857
- `key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
22858
- )
22859
- return
22860
- }
22861
-
22862
- if (action === 'approve') {
22863
- // Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
22864
- // higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
22865
- // the grant must be minted with the operator passphrase — never
22866
- // posture, even under telegram-id mode (the broker enforces the same
22867
- // rule, so a posture mint would just be rejected). So for an
22868
- // admin-only key we (a) reject taps from any non-admin allowFrom
22869
- // member, and (b) skip the telegram-id posture branch below, falling
22870
- // through to the passphrase-prompt path. The card + buttons stay
22871
- // intact on a non-admin tap so the admin can still approve.
22872
- const isAdminOnly = matchesAdminOnlyKey(pending.key, ADMIN_ONLY_KEYS)
22873
- if (isAdminOnly && senderId !== access.allowFrom[0]) {
22874
- await ctx
22875
- .answerCallbackQuery({
22876
- text: '🔒 Admin-only credential — only the owner can approve this.',
22877
- })
22878
- .catch(() => {})
22879
- return
22880
- }
22881
-
22882
- // Posture: telegram-id (opt-in single-factor). The broker is
22883
- // auto-unlocked and we silently hold the passphrase in memory; skip
22884
- // the passphrase-cache lookup + prompt entirely and mint directly.
22885
- // Allowlist check above already attested the operator's Telegram ID.
22886
- // Admin-only keys are excluded — they take the passphrase path below.
22887
- if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
22888
- const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
22889
- if (pending.card_message_id != null) {
22890
- await ctx.api
22891
- .editMessageText(
22892
- pending.chat_id,
22893
- pending.card_message_id,
22894
- richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
22895
- { reply_markup: { inline_keyboard: [] } },
22896
- )
22897
- .catch(() => {})
22898
- }
22899
- await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
22900
- await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'posture' })
22901
- return
22902
- }
22903
-
22904
- // Tap-to-unlock-and-approve: if the operator hasn't unlocked the
22905
- // vault in this chat yet, capture the passphrase via a pending op
22906
- // intercept and resume the approve flow automatically once it
22907
- // arrives — no second tap, no separate /vault unlock detour.
22908
- // Mirrors the `passphrase-for-deferred` flow from #44.
22909
- const cached = vaultPassphraseCache.get(pending.chat_id)
22910
- if (!cached || cached.expiresAt <= Date.now()) {
22911
- if (pending.card_message_id == null) {
22912
- await ctx
22913
- .answerCallbackQuery({ text: 'Card missing — ask the agent to re-issue.' })
22914
- .catch(() => {})
22915
- return
22916
- }
22917
- // #1051: if there's ALREADY a passphrase-for-access-approve
22918
- // pending op for this chat (operator tapped Approve on a
22919
- // sibling card before typing the passphrase), APPEND this
22920
- // stage to the existing queue instead of overwriting. When
22921
- // the passphrase reply lands the text-handler drains every
22922
- // queued stage — both cards get their grant minted off one
22923
- // passphrase entry. Without this, the second Approve tap
22924
- // orphans the first stage.
22925
- const existing = pendingVaultOps.get(pending.chat_id)
22926
- const newItem = {
22927
- stageId,
22928
- cardChatId: pending.chat_id,
22929
- cardMessageId: pending.card_message_id,
22930
- senderId,
22931
- }
22932
- const items =
22933
- existing?.kind === 'passphrase-for-access-approve'
22934
- ? [...existing.items.filter((it) => it.stageId !== stageId), newItem]
22935
- : [newItem]
22936
- pendingVaultOps.set(pending.chat_id, {
22937
- kind: 'passphrase-for-access-approve',
22938
- items,
22939
- startedAt: existing?.kind === 'passphrase-for-access-approve' ? existing.startedAt : Date.now(),
22940
- })
22941
- // Card text differs slightly when joining an existing batch so
22942
- // the operator isn't confused by two "Reply with passphrase"
22943
- // cards open at once.
22944
- const joiningBatch = items.length > 1
22945
- await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
22946
-
22947
- // Strip the buttons on the ORIGINAL card and mark it "waiting" so it
22948
- // can't be re-tapped, but do NOT overload it as the passphrase prompt.
22949
- // An in-place edit fires no notification and stays pinned to the card's
22950
- // old position in the chat, so a busy topic buries it and the operator
22951
- // never sees the passphrase ask — the exact admin-key miss this fixes
22952
- // (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
22953
- // grant was never minted). The prompt goes out as a fresh message below.
22954
- await ctx.api
22955
- .editMessageText(
22956
- pending.chat_id,
22957
- pending.card_message_id,
22958
- richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
22959
- { reply_markup: { inline_keyboard: [] } },
22960
- )
22961
- .catch(() => {})
22962
-
22963
- // The passphrase prompt as a NEW rich message. Three fixes vs. the old
22964
- // in-place edit, all of which the reported bug needed:
22965
- // 1. Real bold/italic — rendered through the sanctioned `richMessage`
22966
- // GFM path (`sendRichMessage`), never a raw string. The old admin
22967
- // and joining-batch branches passed raw markdown to editMessageText
22968
- // (parse_mode=none), so `**`/`_` rendered as literal characters;
22969
- // the "locked" branch even concatenated a string with a
22970
- // `richMessage()` object (→ `[object Object]`). All three are gone.
22971
- // 2. It lands at the BOTTOM of the chat, not stapled to an old card
22972
- // that later messages bury.
22973
- // 3. It fires a notification — `disable_notification` is deliberately
22974
- // NOT set — so the operator is actually pinged to act.
22975
- // Attention-grabbing header, short lines, key in code formatting.
22976
- const promptText = joiningBatch
22977
- ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
22978
- `Type your vault passphrase as your **next message**.\n` +
22979
- `One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
22980
- `_We delete the passphrase message the moment we read it._`
22981
- : isAdminOnly
22982
- ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
22983
- `\`${pending.key}\` is an **admin-only credential**.\n` +
22984
- `Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
22985
- `_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._`
22986
- : `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
22987
- `Your vault is locked.\n` +
22988
- `Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
22989
- `_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._`
22990
-
22991
- // #1075: deleted-topic safe — fall back to the main chat. Wrapped
22992
- // through robustApiCall for flood-wait retries, mirroring the card send.
22993
- await retryWithThreadFallback<{ message_id: number }>(
22994
- robustApiCall,
22995
- (tid) =>
22996
- lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
22997
- ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
22998
- }),
22999
- { threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
23000
- ).catch(() => {})
23001
- return
23002
- }
23003
-
23004
- await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
23005
- await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'passphrase', passphrase: cached.passphrase })
23006
- return
23007
- }
23008
-
23009
- await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
23010
- }
23011
-
23012
- async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
23013
- const senderId = String(ctx.from?.id ?? '')
23014
- const access = loadAccess()
23015
- if (!access.allowFrom.includes(senderId)) {
23016
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
23017
- return
23018
- }
23019
-
23020
- const parts = data.split(':')
23021
- if (parts.length < 3) {
23022
- await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
23023
- return
23024
- }
23025
- const action = parts[1]
23026
- const stageId = parts.slice(2).join(':')
23027
- const pending = pendingVaultRequestSaves.get(stageId)
23028
- if (!pending) {
23029
- await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
23030
- if (ctx.callbackQuery?.message) {
23031
- await ctx.api
23032
- .editMessageText(
23033
- ctx.callbackQuery.message.chat.id,
23034
- ctx.callbackQuery.message.message_id,
23035
- richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
23036
- { reply_markup: { inline_keyboard: [] } },
23037
- )
23038
- .catch(() => {})
23039
- }
23040
- return
23041
- }
23042
-
23043
- if (action === 'discard') {
23044
- pendingVaultRequestSaves.delete(stageId)
23045
- pendingCardStore.remove(stageId)
23046
- await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
23047
- if (pending.card_message_id != null) {
23048
- await ctx.api
23049
- .editMessageText(
23050
- pending.chat_id,
23051
- pending.card_message_id,
23052
- richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
23053
- { reply_markup: { inline_keyboard: [] } },
23054
- )
23055
- .catch(() => {})
23056
- }
23057
- // Wake the agent that called vault_request_save — symmetric with
23058
- // the vra: approve/deny path (#1052/#1150/#1156). Without this the
23059
- // tool returned "waiting for operator", the turn ended, and a
23060
- // Discard left the agent silently idle forever.
23061
- const discardInbound = buildVaultSaveDiscardedInbound({
23062
- ctx: {
23063
- agent: pending.agent,
23064
- key: pending.key,
23065
- chat_id: pending.chat_id,
23066
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
23067
- },
23068
- stageId,
23069
- operatorId: senderId,
23070
- })
23071
- const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
23072
- process.stderr.write(
23073
- `telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
23074
- `key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
23075
- )
23076
- return
23077
- }
23078
-
23079
- if (action === 'rename') {
23080
- // Set up a pending-op intercept so the user's next message is read
23081
- // as the new key name. Same shape as the existing /vault set value
23082
- // capture (gateway.ts uses pendingVaultOps for this).
23083
- pendingVaultOps.set(pending.chat_id, {
23084
- kind: 'rename-vault-save',
23085
- stageId,
23086
- startedAt: Date.now(),
23087
- } as PendingVaultOp)
23088
- // #1150 audit P0: pre-fix the [Save once][Discard][Rename] keyboard
23089
- // stayed live after the rename tap so the operator could re-tap
23090
- // Save with the old key name mid-rename — a Save tap fires the
23091
- // write immediately, racing the rename intercept. Strip the
23092
- // keyboard atomically with a status line that names the rename
23093
- // mode + the proposed new-key prompt. No synthInbound — the
23094
- // agent's `vault_request_save` tool already returned "waiting
23095
- // for operator," and the eventual save success/failure flows
23096
- // its own wake-up below.
23097
- const sourceMsg = ctx.callbackQuery?.message
23098
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
23099
- ? escapeHtmlForTg(sourceMsg.text)
23100
- : ''
23101
- const statusLine =
23102
- `\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
23103
- `The current proposed key is \`${pending.key}\`.`
23104
- await finalizeCallback(ctx, {
23105
- ackText: 'Send the new key name as your next message.',
23106
- newText: baseText ? `${baseText}${statusLine}` : statusLine,
23107
- })
23108
- return
23109
- }
23110
-
23111
- if (action === 'save') {
23112
- // Acknowledge the tap immediately so Telegram doesn't show a
23113
- // stale "spinning" state on the button while we run the write.
23114
- await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
23115
-
23116
- // Restored-after-restart guard: the staged secret VALUE is held in gateway
23117
- // memory only and is never persisted (secrets hygiene). If this card was
23118
- // restored from disk after a gateway restart, the value is gone — we CANNOT
23119
- // complete the write. Degrade gracefully: strip the card, wake the agent
23120
- // with a save-failed (value-lost) synthetic so it re-requests, and stop.
23121
- if (pending.restoredWithoutValue || pending.value.length === 0) {
23122
- pendingVaultRequestSaves.delete(stageId)
23123
- pendingCardStore.remove(stageId)
23124
- if (pending.card_message_id != null) {
23125
- await ctx.api
23126
- .editMessageText(
23127
- pending.chat_id,
23128
- pending.card_message_id,
23129
- 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._`),
23130
- { reply_markup: { inline_keyboard: [] } },
23131
- )
23132
- .catch(() => {})
23133
- }
23134
- const lostInbound = buildVaultSaveFailedInbound({
23135
- ctx: {
23136
- agent: pending.agent,
23137
- key: pending.key,
23138
- chat_id: pending.chat_id,
23139
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
23140
- },
23141
- stageId,
23142
- operatorId: senderId,
23143
- reason: 'staged value lost to a gateway restart — re-request the save',
23144
- })
23145
- const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
23146
- process.stderr.write(
23147
- `telegram gateway: vault_request_save value lost to restart — wake agent=${pending.agent} ` +
23148
- `key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
23149
- )
23150
- return
23151
- }
23152
-
23153
- // #1115 follow-up: the save-approve flow now mirrors the access-
23154
- // approve flow under telegram-id mode — broker `put` accepts
23155
- // `attest_via_posture: true` (server.ts:1448-1500), so the
23156
- // gateway can attest the write without a cached passphrase.
23157
- // Closes the UX gap where tapping Save surfaced a misleading
23158
- // "🔒 Vault is locked" message even when the broker had been
23159
- // auto-unlocked at boot.
23160
- //
23161
- // Branch: under telegram-id mode use the posture-attested put;
23162
- // under passphrase mode keep the existing cached-passphrase +
23163
- // shell-to-CLI path (operator must `/vault unlock` once per
23164
- // chat session to populate `vaultPassphraseCache`).
23165
- let write: { ok: boolean; output: string }
23166
- if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
23167
- // Posture-attested broker put. No passphrase needed. The broker
23168
- // verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
23169
- // unlocked — see server.ts:1448-1500.
23170
- write = await defaultVaultWritePosture(pending.key, pending.value)
23171
- } else {
23172
- // Passphrase mode — fetch the cached passphrase for this chat.
23173
- // If the gateway hasn't seen the user unlock the vault yet, we
23174
- // can't attest the write — surface the unlock prompt.
23175
- const cached = vaultPassphraseCache.get(pending.chat_id)
23176
- if (!cached || cached.expiresAt <= Date.now()) {
23177
- if (pending.card_message_id != null) {
23178
- await ctx.api
23179
- .editMessageText(
23180
- pending.chat_id,
23181
- pending.card_message_id,
23182
- 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.`),
23183
- { reply_markup: { inline_keyboard: [] } },
23184
- )
23185
- .catch(() => {})
23186
- }
23187
- pendingVaultRequestSaves.delete(stageId)
23188
- pendingCardStore.remove(stageId)
23189
- return
23190
- }
23191
- // defaultVaultWrite spawns `switchroom vault set <key>` with the
23192
- // passphrase env set; the CLI forwards the passphrase to the
23193
- // broker put as operator-attestation (#969 P1a), which authorizes
23194
- // new-key creation.
23195
- write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
23196
- }
23197
-
23198
- if (!write.ok) {
23199
- // Route through the structured-error renderer from #969 P0b so
23200
- // failures show the actionable host hint instead of a raw blob.
23201
- const parsed = parseVaultCliError(write.output)
23202
- const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
23203
- const body = rendered.suppressRaw
23204
- ? rendered.html
23205
- : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
23206
- if (pending.card_message_id != null) {
23207
- await ctx.api
23208
- .editMessageText(
23209
- pending.chat_id,
23210
- pending.card_message_id,
23211
- richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
23212
- { reply_markup: { inline_keyboard: [] } },
23213
- )
23214
- .catch(() => {})
23215
- }
23216
- // Leave the staged secret in memory until TTL — operator might
23217
- // retry by re-invoking the same MCP tool, but the value will be
23218
- // re-staged with a new ID. Drop the current stage.
23219
- pendingVaultRequestSaves.delete(stageId)
23220
- pendingCardStore.remove(stageId)
23221
- // Wake the waiting agent with the failure (symmetric with the
23222
- // success/discard paths) so it doesn't assume vault:<key> exists.
23223
- const failReason =
23224
- (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
23225
- const failInbound = buildVaultSaveFailedInbound({
23226
- ctx: {
23227
- agent: pending.agent,
23228
- key: pending.key,
23229
- chat_id: pending.chat_id,
23230
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
23231
- },
23232
- stageId,
23233
- operatorId: senderId,
23234
- reason: failReason,
23235
- })
23236
- const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
23237
- process.stderr.write(
23238
- `telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
23239
- `key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
23240
- )
23241
- return
23242
- }
23243
-
23244
- // Success — mask the value in the card for visual confirmation.
23245
- pendingVaultRequestSaves.delete(stageId)
23246
- pendingCardStore.remove(stageId)
23247
- if (pending.card_message_id != null) {
23248
- await ctx.api
23249
- .editMessageText(
23250
- pending.chat_id,
23251
- pending.card_message_id,
23252
- richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
23253
- { reply_markup: { inline_keyboard: [] } },
23254
- )
23255
- .catch(() => {})
23256
- }
23257
- // Wake the agent that called vault_request_save so it resumes the
23258
- // task that was blocked on this credential (symmetric with the
23259
- // vra: approve path; buffered if the bridge is mid-reconnect).
23260
- const okInbound = buildVaultSaveCompletedInbound({
23261
- ctx: {
23262
- agent: pending.agent,
23263
- key: pending.key,
23264
- chat_id: pending.chat_id,
23265
- ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
23266
- },
23267
- stageId,
23268
- operatorId: senderId,
23269
- })
23270
- const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
23271
- process.stderr.write(
23272
- `telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
23273
- `key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
23274
- )
23275
- return
23276
- }
23277
-
23278
- await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
23279
- }
23280
-
23281
- async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
23282
- const senderId = String(ctx.from?.id ?? '')
23283
- const access = loadAccess()
23284
- if (!access.allowFrom.includes(senderId)) {
23285
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
23286
- return
23287
- }
23288
- // vd:<action>:<deferKey>. deferKey itself contains a colon (chat:msgId)
23289
- // so we slice rather than split — only the first two segments are
23290
- // structural; the rest is the deferKey verbatim.
23291
- const rest = data.slice('vd:'.length)
23292
- const colon = rest.indexOf(':')
23293
- if (colon < 0) {
23294
- await ctx.answerCallbackQuery({ text: 'Malformed callback.' }).catch(() => {})
23295
- return
23296
- }
23297
- const action = rest.slice(0, colon)
23298
- const deferKey = rest.slice(colon + 1)
23299
- const deferred = deferredSecrets.get(deferKey)
23300
- if (!deferred) {
23301
- await ctx.answerCallbackQuery({ text: 'This card expired. Re-send the secret.' }).catch(() => {})
23302
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
23303
- return
23304
- }
23305
-
23306
- const cardChatId = String(ctx.chat?.id ?? '')
23307
- const cardMessageId = ctx.callbackQuery?.message?.message_id
23308
-
23309
- if (action === 'cancel') {
23310
- // Kernel-side dual-dispatch (MIGRATION.md §1): record the deny decision
23311
- // BEFORE the legacy handler clears state, so the audit log captures it
23312
- // even if the editMessageText below races with another tap. Best-effort
23313
- // — broker unreachable falls back to legacy-only.
23314
- await recordDeferredSecretKernelDecision(
23315
- deferred.kernel_request_id,
23316
- 'deny',
23317
- ctx.from?.id ?? 0,
23318
- access.allowFrom,
23319
- )
23320
- deferredSecrets.delete(deferKey)
23321
- await ctx.answerCallbackQuery({ text: 'Discarded.' }).catch(() => {})
23322
- if (cardMessageId != null) {
23323
- await ctx
23324
- .editMessageText('🗑 Discarded — secret was not saved.', {
23325
- reply_markup: { inline_keyboard: [] },
23326
- })
23327
- .catch(() => {})
23328
- }
23329
- return
23330
- }
23331
-
23332
- if (action === 'unlock') {
23333
- // Kernel-side dual-dispatch (MIGRATION.md §1): record the allow_once
23334
- // decision when the user taps unlock. The actual passphrase capture +
23335
- // vault write still happens via the legacy path below — the kernel
23336
- // decision is for audit/state, not secret material (per RFC B). We
23337
- // record at tap-time rather than after passphrase entry so a kernel
23338
- // record exists even if the user abandons the passphrase prompt.
23339
- await recordDeferredSecretKernelDecision(
23340
- deferred.kernel_request_id,
23341
- 'allow_once',
23342
- ctx.from?.id ?? 0,
23343
- access.allowFrom,
23344
- )
23345
- // #1115 follow-up: telegram-id mode silent-defer-save was withdrawn
23346
- // (same reason as the save-callback above — the in-memory
23347
- // passphrase short-circuit became a bypass surface). The
23348
- // deferred-secret save falls through to the cached-passphrase
23349
- // path under all postures. Routing executeDeferredSecretSave
23350
- // through broker-IPC attest_via_posture is a tracked follow-up.
23351
-
23352
- // If a passphrase is already cached we can skip straight to the write.
23353
- // Covers the case where the user had unlocked separately between
23354
- // detection and tap.
23355
- const cached = vaultPassphraseCache.get(cardChatId)
23356
- if (cached && cached.expiresAt > Date.now()) {
23357
- await ctx.answerCallbackQuery({ text: 'Saving…' }).catch(() => {})
23358
- await executeDeferredSecretSave(ctx, deferKey, cached.passphrase, cardMessageId)
23359
- return
23360
- }
23361
-
23362
- if (cardMessageId == null) {
23363
- await ctx.answerCallbackQuery({ text: 'Missing card context.' }).catch(() => {})
23364
- return
23365
- }
23366
- pendingVaultOps.set(cardChatId, {
23367
- kind: 'passphrase-for-deferred',
23368
- deferKey,
23369
- cardChatId,
23370
- cardMessageId,
23371
- startedAt: Date.now(),
23372
- })
23373
- await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
23374
- await ctx
23375
- .editMessageText(
23376
- richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
23377
- { reply_markup: { inline_keyboard: [] } },
23378
- )
23379
- .catch(() => {})
23380
- return
23381
- }
23382
-
23383
- await ctx.answerCallbackQuery({ text: 'Unknown action.' }).catch(() => {})
23384
- }
23385
-
23386
- // ─── Grant wizard helpers (Issue #227) ──────────────────────────────────────
23387
- // TODO: these helpers duplicate server.ts — extract to a shared module in a
23388
- // future refactor once the two entrypoints are proven stable in production.
23389
-
23390
- /** Parse a duration string like "30d", "7h", "365d" into seconds. */
23391
- function parseGrantDuration(s: string): number | null {
23392
- const m = /^(\d+)([dh])$/i.exec(s.trim())
23393
- if (!m) return null
23394
- const n = parseInt(m[1]!, 10)
23395
- if (n <= 0) return null
23396
- return m[2]!.toLowerCase() === 'd' ? n * 86400 : n * 3600
23397
- }
23398
-
23399
- /** Format seconds as a human-readable expiry label. */
23400
- function formatGrantExpiry(ttlSeconds: number | null, now: Date = new Date()): string {
23401
- if (ttlSeconds === null) return 'Never'
23402
- const exp = new Date(now.getTime() + ttlSeconds * 1000)
23403
- return exp.toISOString().slice(0, 10)
23404
- }
23405
-
23406
- /** Build the Step 1 keyboard: agent selection. */
23407
- function buildGrantAgentKeyboard(agents: string[]): InlineKeyboard {
23408
- const kb = new InlineKeyboard()
23409
- // Max 3 per row to keep buttons readable on mobile
23410
- for (let i = 0; i < agents.length; i++) {
23411
- if (i > 0 && i % 3 === 0) kb.row()
23412
- kb.text(agents[i]!, `vg:agent:${agents[i]!}`)
23413
- }
23414
- kb.row().text('Cancel', 'vg:cancel')
23415
- return kb
23416
- }
23417
-
23418
- /** Build the Step 2 keyboard: key multi-select toggle. */
23419
- function buildGrantKeysKeyboard(keys: string[], selected: Set<string>): InlineKeyboard {
23420
- const kb = new InlineKeyboard()
23421
- for (const k of keys) {
23422
- const check = selected.has(k) ? '☑' : '☐'
23423
- kb.row().text(`${check} ${k}`, `vg:key:${k}`)
23424
- }
23425
- kb.row()
23426
- .text('Continue', 'vg:keys-continue')
23427
- .text('Cancel', 'vg:cancel')
23428
- return kb
23429
- }
23430
-
23431
- /** Build the Step 3 keyboard: duration selection. */
23432
- function buildGrantDurationKeyboard(): InlineKeyboard {
23433
- return new InlineKeyboard()
23434
- .text('30 days', 'vg:dur:30d')
23435
- .text('90 days', 'vg:dur:90d')
23436
- .text('1 year', 'vg:dur:1y')
23437
- .row()
23438
- .text('Custom…', 'vg:dur:custom')
23439
- .text('No expiry', 'vg:dur:never')
23440
- .row()
23441
- .text('Back', 'vg:back:duration')
23442
- .text('Cancel', 'vg:cancel')
23443
- }
23444
-
23445
- /** Build the Confirm keyboard. */
23446
- function buildGrantConfirmKeyboard(): InlineKeyboard {
23447
- return new InlineKeyboard()
23448
- .text('Generate', 'vg:generate')
23449
- .text('Cancel', 'vg:cancel')
23450
- }
23451
-
23452
- /** Start the grant wizard (step 1: pick agent). */
23453
- async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void> {
23454
- type AgentListResp = { agents: Array<{ name: string }> }
23455
- const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
23456
- const agents = data?.agents?.map(a => a.name).filter(Boolean) ?? []
23457
- if (agents.length === 0) {
23458
- await switchroomReply(ctx, '⚠️ No agents found in switchroom.yaml.', { html: true })
23459
- return
23460
- }
23461
- const kb = buildGrantAgentKeyboard(agents)
23462
- const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
23463
- const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
23464
- pendingVaultOps.set(chatId, {
23465
- kind: 'grant-wizard',
23466
- step: 'agent',
23467
- wizardMsgId,
23468
- startedAt: Date.now(),
23469
- })
23470
- }
23471
-
23472
- /** Advance grant wizard to step 2 (pick keys). */
23473
- async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wizardMsgId: number | undefined): Promise<void> {
23474
- const keys = await listViaBroker()
23475
- if (!keys) {
23476
- await switchroomReply(ctx, '🔴 Broker is not running (or unreachable). Cannot list vault keys.', { html: true })
23477
- pendingVaultOps.delete(chatId)
23478
- return
23479
- }
23480
- if (keys.length === 0) {
23481
- await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
23482
- pendingVaultOps.delete(chatId)
23483
- return
23484
- }
23485
- const selected = new Set<string>()
23486
- const kb = buildGrantKeysKeyboard(keys, selected)
23487
- const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
23488
- if (wizardMsgId != null) {
23489
- // allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
23490
- await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
23491
- } else {
23492
- const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
23493
- wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
23494
- }
23495
- pendingVaultOps.set(chatId, {
23496
- kind: 'grant-wizard',
23497
- step: 'keys',
23498
- agent,
23499
- selectedKeys: [],
23500
- availableKeys: keys,
23501
- wizardMsgId,
23502
- startedAt: Date.now(),
23503
- })
23504
- }
23505
-
23506
- /** Advance grant wizard to step 3 (pick duration). */
23507
- async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
23508
- const kb = buildGrantDurationKeyboard()
23509
- const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
23510
- const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
23511
- const msgId = state.wizardMsgId
23512
- if (msgId != null) {
23513
- // allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
23514
- await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
23515
- } else {
23516
- const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
23517
- state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
23518
- }
23519
- pendingVaultOps.set(chatId, { ...state, step: 'duration' })
23520
- }
23521
-
23522
- /** Advance grant wizard to confirmation step. */
23523
- async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
23524
- const kb = buildGrantConfirmKeyboard()
23525
- const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
23526
- const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
23527
- const text = [
23528
- '**Confirm grant**',
23529
- '',
23530
- `Agent: \`${state.agent!}\``,
23531
- `Keys:\n${keyList}`,
23532
- `Expires: **${escapeHtmlForTg(expiresLabel)}**`,
23533
- '',
23534
- 'Tap **Generate** to mint the token.',
23535
- ].join('\n')
23536
- const msgId = state.wizardMsgId
23537
- if (msgId != null) {
23538
- // allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
23539
- await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
23540
- } else {
23541
- const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
23542
- state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
23543
- }
23544
- // Mint kernel decision row at the confirm step (MIGRATION.md §2,
23545
- // audit-only Phase 1). We do it here rather than at executeGrantWizard
23546
- // so a kernel row exists even if the user taps Cancel from the confirm
23547
- // card — the deny verdict on cancel is then recorded against the same
23548
- // request_id. If the kernel/broker is unreachable, request_id stays
23549
- // undefined and the wizard runs legacy-only (no behaviour change).
23550
- const kernelRequestId = await mintGrantWizardKernelRequest(
23551
- state.agent!,
23552
- loadAccess().allowFrom,
23553
- state.selectedKeys!,
23554
- state.ttlSeconds ?? null,
23555
- )
23556
- pendingVaultOps.set(chatId, {
23557
- ...state,
23558
- step: 'confirm',
23559
- expiresLabel,
23560
- kernel_request_id: kernelRequestId ?? state.kernel_request_id,
23561
- })
23562
- }
23563
-
23564
- /** Execute the grant: call broker mint_grant, write token, reply. */
23565
- async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
23566
- pendingVaultOps.delete(chatId)
23567
- // Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
23568
- // record the allow_once decision when the user taps Generate. The
23569
- // legacy `mintGrantViaBroker` below still drives the actual grant
23570
- // mint + token write — the kernel row is informational, not
23571
- // enforcing, in Phase 1 (issue #833 will flip to enforcing).
23572
- // We record at tap-time rather than after mint_grant succeeds so a
23573
- // kernel row exists even if the legacy mint fails (audit captures
23574
- // intent regardless of downstream outcome).
23575
- await recordGrantWizardKernelDecision(
23576
- state.kernel_request_id,
23577
- 'allow_once',
23578
- ctx.from?.id ?? 0,
23579
- loadAccess().allowFrom,
23580
- )
23581
- // Defence-in-depth: state.agent flows from callback_data into a path
23582
- // join below. A crafted vg:agent:../../etc payload would produce a
23583
- // path traversal. Validate against the same regex the rest of the
23584
- // file uses; on failure, drop silently — the wizard message has
23585
- // already been finalized.
23586
- try { assertSafeAgentName(state.agent!) } catch { return }
23587
- const result = await mintGrantViaBroker({
23588
- agent: state.agent!,
23589
- keys: state.selectedKeys!,
23590
- ttl_seconds: state.ttlSeconds ?? null,
23591
- description: state.description,
23592
- })
23593
- if (result.kind === 'unreachable') {
23594
- await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
23595
- return
23596
- }
23597
- if (result.kind === 'error') {
23598
- await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
23599
- return
23600
- }
23601
- // Write token to the agent's .vault-token file
23602
- const { token, id } = result
23603
- const tokenPath = join(homedir(), '.switchroom', 'agents', state.agent!, '.vault-token')
23604
- try {
23605
- mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
23606
- writeFileSync(tokenPath, token, { mode: 0o600 })
23607
- } catch (err) {
23608
- await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
23609
- return
23610
- }
23611
- // Collapse wizard message to just the outcome.
23612
- // #1150 audit: P0 fix — pre-fix this `editMessageText` call omitted
23613
- // `reply_markup: { inline_keyboard: [] }` so the wizard's [Generate]
23614
- // / [Cancel] buttons stayed tappable on the success card. Operator
23615
- // could re-tap [Generate] and mint a second redundant grant.
23616
- // Strip the keyboard atomically with the success text via the
23617
- // finalizeCallback helper.
23618
- const msgId = state.wizardMsgId
23619
- const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
23620
- if (msgId != null) {
23621
- await finalizeCallback(ctx, {
23622
- ackText: '✅ Grant created',
23623
- newText: successText,
23624
- // No synthInbound — operator-only flow.
23625
- })
23626
- } else {
23627
- // Fallback when wizard message id was lost (rare; e.g. operator
23628
- // deleted the card). Send a fresh reply with the success text;
23629
- // no keyboard to strip in this branch.
23630
- await switchroomReply(ctx, successText, { html: true })
23631
- }
23632
- }
23633
-
23634
- /**
23635
- * Issue #228: handle vault grant management callbacks.
23636
- *
23637
- * `vg:revoke:<grantId>` — fetch grant details and show confirmation card.
23638
- * `vg:confirm:<grantId>` — call broker revoke_grant, reply with success.
23639
- * `vg:cancel:<grantId>` — dismiss (clear keyboard, no broker call).
23640
- *
23641
- * Issue #227: also handles /vault grant wizard callbacks.
23642
- *
23643
- * `vg:cancel` — cancel wizard at any step.
23644
- * `vg:agent:<name>` — step 1: select agent.
23645
- * `vg:key:<name>` — step 2: toggle key selection.
23646
- * `vg:keys-continue` — step 2 → 3.
23647
- * `vg:dur:<value>` — step 3: duration selection.
23648
- * `vg:back:duration` — step 3 → back to step 2.
23649
- * `vg:generate` — confirm and mint token.
23650
- */
23651
- async function handleVaultGrantCallback(ctx: Context, data: string): Promise<void> {
23652
- const senderId = String(ctx.from?.id ?? '')
23653
- const access = loadAccess()
23654
- if (!access.allowFrom.includes(senderId)) {
23655
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
23656
- return
23657
- }
23658
-
23659
- const revokeMatch = /^vg:revoke:(.+)$/.exec(data)
23660
- if (revokeMatch) {
23661
- const grantId = revokeMatch[1]!
23662
- const result = await listGrantsViaBroker(undefined)
23663
- if (result.kind !== 'ok') {
23664
- await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
23665
- return
23666
- }
23667
- const grant = result.grants.find(g => g.id === grantId)
23668
- if (!grant) {
23669
- await ctx.answerCallbackQuery({ text: 'Grant not found (already revoked?).' }).catch(() => {})
23670
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
23671
- return
23672
- }
23673
- const cardText =
23674
- `🗑 Revoke \`${grantId}\`?\n` +
23675
- `Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
23676
- `Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
23677
- const confirmKeyboard = new InlineKeyboard()
23678
- .text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
23679
- .text('❌ Cancel', `vg:cancel:${grantId}`)
23680
- await ctx.answerCallbackQuery().catch(() => {})
23681
- await ctx.editMessageText(richMessage(cardText), {
23682
- reply_markup: confirmKeyboard,
23683
- }).catch(async () => {
23684
- const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
23685
- const threadId = ctx.callbackQuery?.message?.message_thread_id
23686
- if (chatId) {
23687
- // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
23688
- await swallowingApiCall(
23689
- () =>
23690
- bot.api.sendRichMessage(chatId, richMessage(cardText), {
23691
- reply_markup: confirmKeyboard,
23692
- ...(threadId != null ? { message_thread_id: threadId } : {}),
23693
- }),
23694
- {
23695
- chat_id: chatId,
23696
- verb: 'vault-revoke-confirm-fallback',
23697
- ...(threadId != null ? { threadId } : {}),
23698
- },
23699
- )
23700
- }
23701
- })
23702
- return
23703
- }
23704
-
23705
- const confirmMatch = /^vg:confirm:(.+)$/.exec(data)
23706
- if (confirmMatch) {
23707
- const grantId = confirmMatch[1]!
23708
- const revokeResult = await revokeGrantViaBroker(grantId)
23709
- if (revokeResult.kind === 'unreachable') {
23710
- await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
23711
- return
23712
- }
23713
- if (revokeResult.kind === 'error') {
23714
- await ctx.answerCallbackQuery({ text: `Revoke failed: ${revokeResult.msg}` }).catch(() => {})
23715
- return
23716
- }
23717
- await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
23718
- await ctx.editMessageText(
23719
- richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
23720
- { reply_markup: { inline_keyboard: [] } },
23721
- ).catch(() => {})
23722
- return
23723
- }
23724
-
23725
- const cancelMatch = /^vg:cancel:(.+)$/.exec(data)
23726
- if (cancelMatch) {
23727
- await ctx.answerCallbackQuery({ text: 'Cancelled.' }).catch(() => {})
23728
- await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
23729
- return
23730
- }
23731
-
23732
- // #227 grant wizard callbacks (vg:cancel bare, vg:agent:*, vg:key:*, vg:keys-continue,
23733
- // vg:dur:*, vg:back:*, vg:generate). These come after the management callbacks above
23734
- // because management uses vg:cancel:<id> (with trailing id) while the wizard uses
23735
- // bare vg:cancel — the cancelMatch above only matches the id-suffixed form.
23736
- //
23737
- // Note: pre-#265 fix this function did `await ctx.answerCallbackQuery().catch(() => {})`
23738
- // unconditionally up front. That meant the `vg:keys-continue` branch's
23739
- // toast call (`Select at least one key.`) hit a Telegram error
23740
- // ("query is too old or query ID is invalid") because the query was
23741
- // already answered, and the toast never reached the user. Each branch
23742
- // now owns its own ack.
23743
- const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
23744
- const ackSilently = () => ctx.answerCallbackQuery().catch(() => {})
23745
-
23746
- // Cancel at any wizard step
23747
- if (data === 'vg:cancel') {
23748
- // Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
23749
- // if the user got as far as the confirm step, a kernel request_id
23750
- // will be on the wizard state — record the deny decision so the
23751
- // audit log captures the abandonment. No-op if the user cancelled
23752
- // before the confirm step (or if the kernel was unreachable).
23753
- const cancelState = pendingVaultOps.get(chatId)
23754
- if (cancelState && cancelState.kind === 'grant-wizard') {
23755
- await recordGrantWizardKernelDecision(
23756
- cancelState.kernel_request_id,
23757
- 'deny',
23758
- ctx.from?.id ?? 0,
23759
- loadAccess().allowFrom,
23760
- )
23761
- }
23762
- pendingVaultOps.delete(chatId)
23763
- const msg = ctx.callbackQuery?.message
23764
- if (msg && 'text' in msg) {
23765
- await ctx.editMessageText('❌ Grant wizard cancelled.').catch(() => {})
23766
- }
23767
- await ackSilently()
23768
- return
23769
- }
23770
-
23771
- const state = pendingVaultOps.get(chatId)
23772
- if (!state || state.kind !== 'grant-wizard') {
23773
- await ctx.editMessageText('⚠️ Wizard session expired. Run /vault grant to start again.').catch(() => {})
23774
- await ackSilently()
23775
- return
23776
- }
23777
-
23778
- // vg:agent:<name> — step 1 selection
23779
- if (data.startsWith('vg:agent:')) {
23780
- const agent = data.slice('vg:agent:'.length)
23781
- const msgId = (ctx.callbackQuery?.message as { message_id?: number })?.message_id ?? state.wizardMsgId
23782
- await grantWizardStep2(ctx, chatId, agent, msgId)
23783
- await ackSilently()
23784
- return
23785
- }
23786
-
23787
- // vg:key:<name> — step 2 toggle
23788
- if (data.startsWith('vg:key:')) {
23789
- const key = data.slice('vg:key:'.length)
23790
- if (state.step !== 'keys') { await ackSilently(); return }
23791
- const selectedSet = new Set(state.selectedKeys ?? [])
23792
- if (selectedSet.has(key)) {
23793
- selectedSet.delete(key)
23794
- } else {
23795
- selectedSet.add(key)
23796
- }
23797
- const updatedState = { ...state, selectedKeys: [...selectedSet] }
23798
- pendingVaultOps.set(chatId, updatedState)
23799
- const kb = buildGrantKeysKeyboard(state.availableKeys ?? [], selectedSet)
23800
- await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {})
23801
- await ackSilently()
23802
- return
23803
- }
23804
-
23805
- // vg:keys-continue — step 2 → 3
23806
- if (data === 'vg:keys-continue') {
23807
- if (state.step !== 'keys') { await ackSilently(); return }
23808
- if (!state.selectedKeys || state.selectedKeys.length === 0) {
23809
- // Toast-only ack: this is the branch the unconditional pre-ack
23810
- // used to silently swallow. See #265.
23811
- await ctx.answerCallbackQuery({ text: 'Select at least one key.' }).catch(() => {})
23812
- return
23813
- }
23814
- await grantWizardStep3(ctx, chatId, state)
23815
- await ackSilently()
23816
- return
23817
- }
23818
-
23819
- // vg:dur:<value> — step 3 duration selection
23820
- if (data.startsWith('vg:dur:')) {
23821
- if (state.step !== 'duration') { await ackSilently(); return }
23822
- const dur = data.slice('vg:dur:'.length)
23823
- if (dur === 'custom') {
23824
- // Ask for text reply with n d|h format
23825
- pendingVaultOps.set(chatId, { ...state, awaitingCustomDuration: true })
23826
- const msg = ctx.callbackQuery?.message
23827
- if (msg && 'text' in msg && msg.text) {
23828
- // Escape source text before re-rendering with HTML parse mode.
23829
- // `msg.text` returns entities-stripped plain UTF-8; a raw
23830
- // `<`/`>`/`&` in the wizard's prior-step body (e.g. a future
23831
- // key or label) would crash the HTML re-parse and the bare
23832
- // `.catch(() => {})` would swallow the failure silently — same
23833
- // hazard PR #1158 caught on the operator-event card.
23834
- await ctx.editMessageText(
23835
- richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
23836
- { reply_markup: buildGrantDurationKeyboard() },
23837
- ).catch(() => {})
23838
- }
23839
- await ackSilently()
23840
- return
23841
- }
23842
- let ttlSeconds: number | null
23843
- if (dur === 'never') {
23844
- ttlSeconds = null
23845
- } else if (dur === '1y') {
23846
- ttlSeconds = 365 * 86400
23847
- } else {
23848
- ttlSeconds = parseGrantDuration(dur)
23849
- if (ttlSeconds === null) { await ackSilently(); return }
23850
- }
23851
- const newState = { ...state, ttlSeconds, awaitingCustomDuration: false }
23852
- await grantWizardConfirm(ctx, chatId, newState)
23853
- await ackSilently()
23854
- return
23855
- }
23856
-
23857
- // vg:back:duration — go back to step 2 (keys selection) from step 3
23858
- if (data === 'vg:back:duration') {
23859
- if (state.step !== 'duration') { await ackSilently(); return }
23860
- const msgId = state.wizardMsgId
23861
- await grantWizardStep2(ctx, chatId, state.agent!, msgId)
23862
- await ackSilently()
23863
- return
23864
- }
23865
-
23866
- // vg:generate — final step
23867
- if (data === 'vg:generate') {
23868
- if (state.step !== 'confirm') { await ackSilently(); return }
23869
- await executeGrantWizard(ctx, chatId, state)
23870
- await ackSilently()
23871
- return
23872
- }
23873
-
23874
- // Unrecognised vg: sub-action
23875
- await ackSilently()
23876
- }
23877
-
23878
- /**
23879
- * Issue #44: write a deferred secret to the vault using the now-cached
23880
- * passphrase. Confirms with a masked ref + slug; matches the "captured
23881
- * N secret" UX of the cached-passphrase happy path so the user
23882
- * experience is identical regardless of which path they came in on.
23883
- *
23884
- * Called from two places:
23885
- * - The `passphrase-for-deferred` branch of the text-handler
23886
- * pendingVaultOps intercept, after the passphrase is verified.
23887
- * - The `vd:unlock` callback handler when a passphrase happens to
23888
- * already be cached (rare but possible).
23889
- *
23890
- * If write fails, the deferred entry is preserved so the user can retry.
23891
- */
23892
- async function executeDeferredSecretSave(
23893
- ctx: Context,
23894
- deferKey: string,
23895
- passphrase: string,
23896
- cardMessageId: number | undefined,
23897
- ): Promise<void> {
23898
- const deferred = deferredSecrets.get(deferKey)
23899
- if (!deferred) {
23900
- if (cardMessageId != null) {
23901
- await ctx.api
23902
- .editMessageText(
23903
- deferKey.split(':')[0]!,
23904
- cardMessageId,
23905
- '⚠️ This card expired before unlock — please re-send the secret.',
23906
- { reply_markup: { inline_keyboard: [] } },
23907
- )
23908
- .catch(() => {})
23909
- }
23910
- return
23911
- }
23912
-
23913
- // De-duplicate suggested_slug against existing vault keys by appending
23914
- // _2 / _3 / … if needed. Same logic as the cached-passphrase happy
23915
- // path uses (gateway.ts ~L2402 stash command).
23916
- const slugBase = deferred.suggested_slug || 'secret'
23917
- const listed = defaultVaultList(passphrase)
23918
- const existing = new Set(listed.ok ? listed.keys : [])
23919
- let slug = slugBase
23920
- let n = 2
23921
- while (existing.has(slug)) slug = `${slugBase}_${n++}`
23922
-
23923
- const write = defaultVaultWrite(slug, deferred.text, passphrase)
23924
- if (!write.ok) {
23925
- // Classify the failure via the structured stderr markers emitted by
23926
- // `switchroom vault` (issue #969 P0a). If it's a recognised marker,
23927
- // render a clean actionable message instead of dumping the raw
23928
- // "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
23929
- // emits — that was the misleading-error half of #968.
23930
- //
23931
- // Keep the deferred entry so the user can retry by tapping again
23932
- // once the underlying condition is fixed (broker started, host
23933
- // approval granted, etc.).
23934
- const parsed = parseVaultCliError(write.output)
23935
- const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
23936
- const body = rendered.suppressRaw
23937
- ? rendered.html
23938
- : `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
23939
- if (cardMessageId != null) {
23940
- await ctx.api
23941
- .editMessageText(
23942
- deferred.chat_id,
23943
- cardMessageId,
23944
- richMessage(`${body}\n\nRe-tap to retry.`),
23945
- {
23946
- reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
23947
- ? buildDeferredSecretKeyboard(deferKey)
23948
- : undefined,
23949
- },
23950
- )
23951
- .catch(() => {})
23952
- }
23953
- return
23954
- }
23955
-
23956
- deferredSecrets.delete(deferKey)
23957
- const masked = maskToken(deferred.text)
23958
- if (cardMessageId != null) {
23959
- await ctx.api
23960
- .editMessageText(
23961
- deferred.chat_id,
23962
- cardMessageId,
23963
- richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
23964
- { reply_markup: { inline_keyboard: [] } },
23965
- )
23966
- .catch(() => {})
23967
- }
23968
- // Stage for follow-up rename, mirroring the cached-passphrase path.
23969
- secretStaging.set({
23970
- chat_id: deferred.chat_id,
23971
- message_id: deferred.original_message_id,
23972
- detection: {
23973
- rule_id: 'deferred',
23974
- matched_text: deferred.text,
23975
- start: 0,
23976
- end: deferred.text.length,
23977
- confidence: 'high' as const,
23978
- suppressed: false,
23979
- suggested_slug: slug,
23980
- },
23981
- staged_at: Date.now(),
23982
- })
23983
- }
23984
-
23985
- async function handleOperatorEventCallback(ctx: Context, data: string): Promise<void> {
23986
- const senderId = String(ctx.from?.id ?? '')
23987
- const access = loadAccess()
23988
- if (!access.allowFrom.includes(senderId)) {
23989
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
23990
- return
23991
- }
23992
-
23993
- // Parse op:<action>:<encoded-agent>
23994
- const parts = data.slice(3).split(':', 2) // drop "op:", then split action:agent
23995
- if (parts.length !== 2) {
23996
- await ctx.answerCallbackQuery({ text: 'Malformed operator-event callback.' }).catch(() => {})
23997
- return
23998
- }
23999
- const [action, encodedAgent] = parts
24000
- let agent: string
24001
- try {
24002
- agent = decodeURIComponent(encodedAgent)
24003
- } catch {
24004
- await ctx.answerCallbackQuery({ text: 'Bad agent name encoding.' }).catch(() => {})
24005
- return
24006
- }
24007
- if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(agent)) {
24008
- await ctx.answerCallbackQuery({ text: 'Invalid agent name.' }).catch(() => {})
24009
- return
24010
- }
24011
-
24012
- // #1150 audit P1: extract the source card text once so every branch
24013
- // below can append a status line via finalizeCallback. Pre-fix `dismiss`
24014
- // and `restart` stripped the keyboard but kept the original card body
24015
- // verbatim — operator scrolling back couldn't see what they'd decided.
24016
- // `reauth` didn't strip the keyboard at all → re-tappable mid-flow.
24017
- //
24018
- // HTML-escape the extracted text before concatenation. Telegram returns
24019
- // `msg.text` as plain UTF-8 with entities stripped — any raw `<`, `>`,
24020
- // or `&` characters in the original `detail` (operator-events.ts
24021
- // `unknown-4xx`/`unknown-5xx` cards routinely carry API error bodies
24022
- // with `<`/`>` in them) would be re-parsed as HTML tags when the
24023
- // finalizeCallback edit fires with `parseMode: 'HTML'`. Telegram
24024
- // rejects the edit, finalizeCallback's catch swallows it, the
24025
- // keyboard never strips, and the operator re-taps → exact bug this
24026
- // PR is meant to fix re-introduced. Escape once here so every branch
24027
- // gets a safe-to-reparse value. We lose the original bold/italic
24028
- // styling on the source body — acceptable, that styling was already
24029
- // gone the moment `msg.text` was read instead of `msg.entities`.
24030
- // (PR #1158 round 2 — review item F.)
24031
- const sourceMsgText = (() => {
24032
- const msg = ctx.callbackQuery?.message
24033
- if (!msg || !('text' in msg) || !msg.text) return ''
24034
- return escapeHtmlForTg(msg.text)
24035
- })()
24036
-
24037
- switch (action) {
24038
- case 'dismiss': {
24039
- // #1150 audit P1: was strip-only. Now appends a status line so
24040
- // scrollback shows the dismissal.
24041
- const status = `\n\n✗ _Dismissed by operator._`
24042
- await finalizeCallback(ctx, {
24043
- ackText: 'Dismissed',
24044
- newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
24045
- // No synthInbound — dismiss is operator-only, no model in loop.
24046
- })
24047
- return
24048
- }
24049
- case 'restart': {
24050
- const ok = triggerSelfRestart(agent, 'inline-button-restart')
24051
- if (ok) {
24052
- // #1150 audit P1: was reply + editMessageReplyMarkup (two
24053
- // separate edits). Atomic via finalizeCallback now — the
24054
- // status line is the announcement, no separate reply needed.
24055
- const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
24056
- await finalizeCallback(ctx, {
24057
- ackText: `Restarting ${agent}…`,
24058
- newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
24059
- })
24060
- } else {
24061
- // Failure-path: leave the keyboard tappable so the operator
24062
- // can retry once they've followed the manual instructions
24063
- // below. ack toast still fires.
24064
- await ctx.answerCallbackQuery({ text: `Restart failed for ${agent}` }).catch(() => {})
24065
- const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
24066
- const detail = isDocker
24067
- ? `cross-agent restart is not supported under docker. ` +
24068
- `Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
24069
- : 'restart trigger failed'
24070
- await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
24071
- }
24072
- return
24073
- }
24074
- case 'reauth': {
24075
- // #1150 audit P1: pre-fix the operator-event card's [Reauth] button
24076
- // stayed tappable after the reauth flow started → operator could
24077
- // re-tap and spawn a second concurrent flow that fights the first
24078
- // for the login URL state. Strip the keyboard and append a status
24079
- // line; the new reauth-flow's own messages appear below the
24080
- // collapsed card.
24081
- const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
24082
- await finalizeCallback(ctx, {
24083
- ackText: `Starting reauth for ${agent}…`,
24084
- newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
24085
- synthInbound: async () => {
24086
- await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
24087
- // PR3 supergroup-mode: key by (chat, thread) so an OAuth code
24088
- // pasted into a different topic isn't mistakenly intercepted
24089
- // as this flow's reauth code.
24090
- const reauthThreadId = ctx.callbackQuery?.message?.message_thread_id
24091
- pendingReauthFlows.set(
24092
- chatKey(String(ctx.chat!.id), reauthThreadId ?? null) as string,
24093
- { agent, startedAt: Date.now() },
24094
- )
24095
- },
24096
- })
24097
- return
24098
- }
24099
- case 'logs': {
24100
- await ctx.answerCallbackQuery({ text: 'Fetching logs…' }).catch(() => {})
24101
- // Pick the right log source for the runtime. Under docker, the
24102
- // gateway is INSIDE the agent container — calling `docker logs`
24103
- // requires the host's docker socket which is deliberately not
24104
- // mounted into agent containers. Under systemd, journalctl
24105
- // works as before. v0.7.2 fixed `case 'restart'` but left this
24106
- // path systemd-only.
24107
- const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
24108
- if (isDocker) {
24109
- await ctx.replyWithRichMessage(richMessage(
24110
- `_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
24111
- `Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
24112
- ))
24113
- return
24114
- }
24115
- try {
24116
- const out = execFileSync(
24117
- 'journalctl',
24118
- ['--user', '-u', `switchroom-${agent}`, '-n', '30', '--no-pager', '--output=short-monotonic'],
24119
- { encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
24120
- ) as string
24121
- const trimmed = out.trim().slice(-3500)
24122
- await ctx.replyWithRichMessage(richMessage(
24123
- trimmed
24124
- ? `\`\`\`\n${trimmed.replace(/```/g, '`​``')}\n\`\`\``
24125
- : `_No logs for ${agent}._`,
24126
- ))
24127
- } catch (err) {
24128
- await ctx.replyWithRichMessage(richMessage(
24129
- `**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
24130
- ))
24131
- }
24132
- return
24133
- }
24134
- default: {
24135
- await ctx.answerCallbackQuery({ text: `Unknown action: ${action}` }).catch(() => {})
24136
- return
24137
- }
24138
- }
24139
- }
24140
-
24141
- // RFC H §7.3: the dashboard callback dispatcher is gone — there are
24142
- // no auth: callback buttons in the new chat surface. We keep a no-op
24143
- // stub so any stale pinned message that fires an `auth:*` tap is
24144
- // silently dismissed instead of crashing the gateway.
24145
- async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
24146
- const data = ctx.callbackQuery?.data ?? ''
24147
- const currentAgent = getMyAgentName()
24148
-
24149
- // auth:use:<label> — fleet-wide swap via broker.setActive (same path
24150
- // /auth use takes from chat). Admin-gated via the broker's own
24151
- // per-agent admin flag.
24152
- if (data.startsWith('auth:use:')) {
24153
- const label = data.slice('auth:use:'.length)
24154
- if (!label) {
24155
- try { await ctx.answerCallbackQuery({ text: 'Missing account label.', show_alert: false }) } catch { /* */ }
24156
- return
24157
- }
24158
- try {
24159
- const client = await getAuthBrokerClient(currentAgent)
24160
- if (!client) {
24161
- try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
24162
- return
24163
- }
24164
- const result = await client.setActive(label)
24165
- try {
24166
- await ctx.answerCallbackQuery({
24167
- text: `Switched fleet → ${result.active} (${result.fanned.length} agents)`,
24168
- show_alert: false,
24169
- })
24170
- } catch { /* toast may fail on stale tap */ }
24171
- // Edit the source message to reflect the new active. Leaving
24172
- // the old keyboard intact would tempt a double-tap; we replace
24173
- // the text + drop the keyboard so the user has to /auth again
24174
- // to see fresh state.
24175
- const msg = ctx.callbackQuery?.message
24176
- if (msg) {
24177
- // Wrap in swallowingApiCall per #1075 — stale callback-source
24178
- // messages (deleted topic, expired) shouldn't crash the swap.
24179
- await swallowingApiCall(
24180
- () =>
24181
- bot.api.editMessageText(
24182
- msg.chat.id,
24183
- msg.message_id,
24184
- richMessage(
24185
- `**Active account →** \`${result.active}\`\n` +
24186
- `_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
24187
- `_Tap /auth to see updated quota for the new active account._`,
24188
- ),
24189
- {},
24190
- ),
24191
- { chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
24192
- )
24193
- }
24194
- } catch (err) {
24195
- const msg = (err as Error)?.message ?? String(err)
24196
- try {
24197
- await ctx.answerCallbackQuery({
24198
- text: `Switch failed: ${msg.slice(0, 180)}`,
24199
- show_alert: true,
24200
- })
24201
- } catch { /* */ }
24202
- }
24203
- return
24204
- }
24205
-
24206
- // auth:refresh — re-render the /auth snapshot in-place with a fresh
24207
- // live probe. Replaces the message body; keyboard stays. The `:demo`
24208
- // variant re-renders with email masking intact (a ↻ tap on an
24209
- // `/auth demo` / `/usage demo` card must not unmask mid-recording).
24210
- if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
24211
- const refreshDemo = data === 'auth:refresh:demo'
24212
- // Freshness throttle: each refresh fan-fires N live api.anthropic.com
24213
- // probes (one per account — forceLive bypasses the broker's 45s
24214
- // probe-on-open TTL, because an explicit ↻ tap is the user asking
24215
- // for live-now data). Without this, a user double-tapping the ↻
24216
- // button burns through their account's RPM budget on duplicate
24217
- // work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
24218
- // pair.
24219
- const refreshMsg = ctx.callbackQuery?.message
24220
- if (refreshMsg) {
24221
- const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
24222
- const lastAtMs = lastAuthRefreshAtMs.get(key) ?? 0
24223
- const sinceLastMs = Date.now() - lastAtMs
24224
- if (sinceLastMs < AUTH_REFRESH_THROTTLE_MS) {
24225
- const waitS = Math.ceil((AUTH_REFRESH_THROTTLE_MS - sinceLastMs) / 1000)
24226
- try {
24227
- await ctx.answerCallbackQuery({
24228
- text: `Just refreshed — try again in ${waitS}s`,
24229
- show_alert: false,
24230
- })
24231
- } catch { /* */ }
24232
- return
24233
- }
24234
- lastAuthRefreshAtMs.set(key, Date.now())
24235
- }
24236
- try {
24237
- const client = await getAuthBrokerClient(currentAgent)
24238
- if (!client) {
24239
- try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
24240
- return
24241
- }
24242
- const state = await client.listState()
24243
- // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
24244
- // forceLive=true: an explicit ↻ tap must bypass the broker's
24245
- // probe-on-open TTL — pre-fix, a tap inside the TTL window served
24246
- // the cached snapshot while stamping "Live · refreshed 0s ago".
24247
- const probeResp = state.accounts.length > 0
24248
- ? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
24249
- : { results: [] }
24250
- // #2495 Change 2 — even under forceLive a failed upstream probe falls
24251
- // back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
24252
- // instead of a false live stamp, same as the /auth and /usage paths.
24253
- const { quotas, staleCachedAtMs } = zipProbeResults(
24254
- state.accounts.map((a) => a.label),
24255
- probeResp.results,
24256
- )
24257
- const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
24258
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
24259
- '../auth-snapshot-format.js'
24260
- )
24261
- const snapshots = buildSnapshotsFromState(state, quotas)
24262
- // Single clock for card body + keyboard so health classification
24263
- // can't disagree between the two (#2495 folded nit A).
24264
- const renderNow = new Date()
24265
- const text = renderAuthSnapshotFormat2(snapshots, {
24266
- tz,
24267
- now: renderNow,
24268
- demo: refreshDemo,
24269
- // Honesty backstop (same as /usage): a TOTAL probe failure (zero
24270
- // result rows, nothing served from cache) renders an explicit
24271
- // "probe failed" marker instead of a false "Live" footer next to
24272
- // no-data rows.
24273
- ...(staleCachedAtMs != null
24274
- ? { staleCachedAtMs }
24275
- : probeResp.results.length > 0
24276
- ? { liveProbedAtMs: renderNow.getTime() }
24277
- : { probeFailed: true }),
24278
- })
24279
- const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
24280
- const inline_keyboard = kbRows.map((row) =>
24281
- row.map((b) => {
24282
- if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
24283
- if (b.insertText) return { text: b.text, switch_inline_query_current_chat: b.insertText }
24284
- return { text: b.text, callback_data: 'auth:noop' }
24285
- }),
24286
- )
24287
- const msg = ctx.callbackQuery?.message
24288
- if (msg) {
24289
- await swallowingApiCall(
24290
- () =>
24291
- bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
24292
- reply_markup: { inline_keyboard },
24293
- }),
24294
- { chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
24295
- )
24296
- }
24297
- try { await ctx.answerCallbackQuery({ text: 'Refreshed.', show_alert: false }) } catch { /* */ }
24298
- } catch (err) {
24299
- const msg = (err as Error)?.message ?? String(err)
24300
- try {
24301
- await ctx.answerCallbackQuery({
24302
- text: `Refresh failed: ${msg.slice(0, 180)}`,
24303
- show_alert: true,
24304
- })
24305
- } catch { /* */ }
24306
- }
24307
- return
24308
- }
24309
-
24310
- // Unknown auth:* — likely from a too-old message. Dismiss with a
24311
- // hint pointing at the canonical re-render verb.
24312
- try {
24313
- await ctx.answerCallbackQuery({
24314
- text: 'Unknown auth button. Send /auth for current state.',
24315
- show_alert: false,
24316
- })
24317
- } catch { /* */ }
24318
- }
24319
-
24320
- // /reauth was removed in v0.6.13 — the `/auth` dashboard's
24321
- // `🔄 Reauth default` button fires the same flow (the `case 'reauth':`
24322
- // callback dispatch calls `runSwitchroomAuthCommand` and seeds
24323
- // `pendingReauthFlows`). The OAuth code paste-back is caught by the
24324
- // generic message intercept that watches `pendingReauthFlows` —
24325
- // pasting the code into chat now Just Works without a typed entry
24326
- // point. Removed surfaces in this PR:
24327
- // - bot.command('reauth', ...) → use /auth → 🔄 Reauth
24328
- // - /reauth <code|url> paste-back → paste into chat
24329
- // - /reauth <other-agent> targeting → use that agent's /auth
24330
-
24331
- bot.command('vault', async ctx => {
24332
- if (!isAuthorizedSender(ctx)) return
24333
- const chatId = String(ctx.chat!.id)
24334
- const args = (typeof ctx.match === "string" ? ctx.match : "").trim().split(/\s+/).filter(Boolean)
24335
- const sub = args[0]?.toLowerCase()
24336
- const key = args[1]
24337
- if (!sub || sub === 'help') {
24338
- await switchroomReply(ctx, [
24339
- '**Vault commands**',
24340
- '/vault list — list all secret keys',
24341
- '/vault get <key> — read a secret value',
24342
- '/vault set <key> — set a secret',
24343
- '/vault delete <key> — remove a secret',
24344
- '/vault status — show broker state',
24345
- '/vault unlock — unlock the broker (prompts for passphrase)',
24346
- '/vault lock — lock the broker',
24347
- '/vault grant — mint a capability token (inline wizard)',
24348
- '/vault grants [agent] — list active capability grants (tap to revoke)',
24349
- '/vault audit <agent> — unified view of an agent\'s vault access',
24350
- '',
24351
- 'Your passphrase is cached in memory for 30 min after first use.',
24352
- ].join('\n'), { html: true })
22101
+ bot.command('vault', async ctx => {
22102
+ if (!isAuthorizedSender(ctx)) return
22103
+ const chatId = String(ctx.chat!.id)
22104
+ const args = (typeof ctx.match === "string" ? ctx.match : "").trim().split(/\s+/).filter(Boolean)
22105
+ const sub = args[0]?.toLowerCase()
22106
+ const key = args[1]
22107
+ if (!sub || sub === 'help') {
22108
+ await switchroomReply(ctx, [
22109
+ '**Vault commands**',
22110
+ '/vault list list all secret keys',
22111
+ '/vault get <key> read a secret value',
22112
+ '/vault set <key> set a secret',
22113
+ '/vault delete <key> remove a secret',
22114
+ '/vault status — show broker state',
22115
+ '/vault unlock unlock the broker (prompts for passphrase)',
22116
+ '/vault lock lock the broker',
22117
+ '/vault grant — mint a capability token (inline wizard)',
22118
+ '/vault grants [agent] — list active capability grants (tap to revoke)',
22119
+ '/vault audit <agent> — unified view of an agent\'s vault access',
22120
+ '',
22121
+ 'Your passphrase is cached in memory for 30 min after first use.',
22122
+ ].join('\n'), { html: true })
24353
22123
  return
24354
22124
  }
24355
22125
 
@@ -24887,150 +22657,27 @@ async function renderFleetDoctor(ctx: Context): Promise<void> {
24887
22657
  await switchroomReply(ctx, formatDoctorReport(body), { html: true })
24888
22658
  }
24889
22659
 
24890
- bot.command('doctor', async ctx => {
24891
- if (!isAuthorizedSender(ctx)) return
24892
- try {
24893
- // Admin agents with hostd reachable choose scope (one tap, no
24894
- // approval card — doctor is read-only). Everyone else keeps the
24895
- // original zero-extra-tap in-container behaviour.
24896
- if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
24897
- await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
24898
- html: true,
24899
- reply_markup: buildDoctorScopeKeyboard(),
24900
- })
24901
- return
24902
- }
24903
- await renderSelfDoctor(ctx)
24904
- } catch (err: unknown) {
24905
- await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
24906
- }
24907
- })
24908
-
24909
- bot.command('grant', async ctx => {
24910
- if (!isAuthorizedSender(ctx)) return
24911
- const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
24912
- if (parts.length === 0) { await switchroomReply(ctx, 'Usage: /grant <tool> or /grant <agent> <tool>'); return }
24913
- let agentName: string; let tool: string
24914
- if (parts.length === 1) { agentName = getMyAgentName(); tool = parts[0] }
24915
- else { agentName = parts[0]; tool = parts.slice(1).join(' ') }
24916
- try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
24917
- await runSwitchroomCommand(ctx, ['agent', 'grant', agentName, tool], `grant ${agentName} ${tool}`)
24918
- })
24919
-
24920
- bot.command('dangerous', async ctx => {
24921
- if (!isAuthorizedSender(ctx)) return
24922
- const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
24923
- let agentName: string; let off = false
24924
- if (parts.length === 0) { agentName = getMyAgentName() }
24925
- else if (parts.length === 1 && parts[0] === 'off') { agentName = getMyAgentName(); off = true }
24926
- else { agentName = parts[0]; if (parts[1] === 'off') off = true }
24927
- try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
24928
- const args = ['agent', 'dangerous', agentName]; if (off) args.push('--off')
24929
- await runSwitchroomCommand(ctx, args, `dangerous ${agentName}${off ? ' off' : ''}`)
24930
- })
24931
-
24932
- bot.command('permissions', async ctx => {
24933
- if (!isAuthorizedSender(ctx)) return
24934
- const agentName = (typeof ctx.match === "string" ? ctx.match : "").trim() || getMyAgentName()
24935
- try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
24936
- await runSwitchroomCommand(ctx, ['agent', 'permissions', agentName], `permissions ${agentName}`)
24937
- })
24938
-
24939
- // Drive-by cleanup (#927): the dead /update handler that lived here
24940
- // was a pre-#919 stub. Grammy registers in order so the comprehensive
24941
- // /update handler at line ~6516 (added in #919, hardened in #924,
24942
- // docker-guarded in #934) fired first and this one never ran.
24943
- // Removed to avoid future confusion.
24944
-
24945
- bot.command('version', async ctx => {
24946
- if (!isAuthorizedSender(ctx)) return
24947
- try {
24948
- let output: string
24949
- try { output = switchroomExecCombined(['version'], 10000) }
24950
- catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'version failed' }
24951
- const trimmed = stripAnsi(output).trim()
24952
- if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
24953
- await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
24954
- } catch (err: unknown) {
24955
- await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
24956
- }
24957
- })
24958
-
24959
-
24960
- // /whoami — the operator's view of THIS agent's sandbox (the same
24961
- // `config whoami` the agent itself can call as an MCP tool, and the host CLI
24962
- // exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
24963
- // tools / MCP / vault key-NAMES (never values) / powers so the operator can
24964
- // see at a glance what this agent is authorized for.
24965
- bot.command('whoami', async ctx => {
24966
- if (!isAuthorizedSender(ctx)) return
24967
- const demo = hasDemoFlag(getCommandArgs(ctx))
24968
- try {
24969
- let raw: string
24970
- try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
24971
- catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
24972
- const trimmed = stripAnsi(raw).trim()
24973
- let card: string
24974
- try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed), demo) }
24975
- catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
24976
- await switchroomReply(ctx, card, { html: true })
24977
- } catch (err: unknown) {
24978
- await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
24979
- }
24980
- })
24981
-
24982
- /** Compact HTML card from the `config whoami` JSON view. Names/booleans only.
24983
- * `demo` (the `/whoami demo` suffix) masks the vault key NAMES via maskVaultKey
24984
- * for screen recordings — agent/MCP/model/skills topology is left untouched
24985
- * (out of scope). Off by default. */
24986
- function formatWhoamiCard(v: {
24987
- name?: string; persona?: string | null; model?: string | null; tier?: string;
24988
- tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
24989
- vault?: { key: string; readable: boolean }[];
24990
- powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
24991
- scheduleCount?: number; memoryBackend?: string | null;
24992
- }, demo = false): string {
24993
- const esc = escapeHtmlForTg
24994
- const yn = (b?: boolean) => (b ? '✓' : '✗')
24995
- const lines: string[] = []
24996
- lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
24997
- if (v.persona) lines.push(esc(v.persona))
24998
- if (v.model) lines.push(`Model: ${esc(v.model)}`)
24999
- const allow = v.tools?.allow ?? []
25000
- lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
25001
- if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
25002
- if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
25003
- if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
25004
- if ((v.vault ?? []).length) {
25005
- lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(demo ? maskVaultKey(k.key) : k.key)} ${yn(k.readable)}`).join(', ')}`)
25006
- }
25007
- const p = v.powers ?? {}
25008
- lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
25009
- lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
25010
- return lines.join('\n')
25011
- }
25012
-
25013
- bot.command('commands', async ctx => {
25014
- if (!isAuthorizedSender(ctx)) return
25015
- await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
22660
+ // Ops/info slash commands (/doctor /grant /dangerous /permissions /version
22661
+ // /whoami /commands) extracted verbatim to bot-commands-ops-info.ts (#2996
22662
+ // Phase 5). Registered here to preserve grammy's in-order registration.
22663
+ registerOpsInfoCommands(bot, {
22664
+ AGENT_ADMIN,
22665
+ isAuthorizedSender,
22666
+ getMyAgentName,
22667
+ switchroomReply,
22668
+ buildDoctorScopeKeyboard,
22669
+ renderSelfDoctor,
22670
+ preBlock,
22671
+ formatSwitchroomOutput,
22672
+ getCommandArgs,
22673
+ assertSafeAgentName,
22674
+ runSwitchroomCommand,
22675
+ switchroomExecCombined,
22676
+ stripAnsi,
22677
+ hasDemoFlag,
22678
+ escapeHtmlForTg,
25016
22679
  })
25017
22680
 
25018
- async function registerSwitchroomBotCommands(): Promise<void> {
25019
- // Slash-menu is deliberately trimmed from the full command catalogue.
25020
- // See telegram-plugin/welcome-text.ts TELEGRAM_MENU_COMMANDS for the
25021
- // rationale (mobile UX focus; ops primitives stay typable but out of
25022
- // the autocomplete clutter). /commands surfaces the full list.
25023
- await bot.api.setMyCommands(
25024
- [...TELEGRAM_BASE_COMMANDS, ...TELEGRAM_SWITCHROOM_COMMANDS],
25025
- { scope: { type: 'all_private_chats' } },
25026
- )
25027
- // Group chats don't support /start pairing, so only the switchroom
25028
- // commands are registered there.
25029
- await bot.api.setMyCommands(
25030
- TELEGRAM_SWITCHROOM_COMMANDS,
25031
- { scope: { type: 'all_group_chats' } },
25032
- )
25033
- }
25034
22681
 
25035
22682
  // ─── Inline-button handler (permissions) ──────────────────────────────────
25036
22683
  // Handles `perm:(allow|deny|always|asn|asb|back):<id>` — permission request buttons
@@ -27991,7 +25638,7 @@ void (async () => {
27991
25638
  // lifetime.
27992
25639
  scheduleAlwaysAllowPersistDrain()
27993
25640
 
27994
- void registerSwitchroomBotCommands().catch(() => {})
25641
+ void registerSwitchroomBotCommands(bot).catch(() => {})
27995
25642
 
27996
25643
  // #613 fix: pre-warm the chatAvailableReactions cache for every
27997
25644
  // chat in access.allowFrom. Without this, the FIRST inbound