switchroom 0.18.12 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -34,6 +34,7 @@ import {
34
34
  type AskUserArgs,
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
+ import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
37
38
  import { parseInterruptMarker } from '../interrupt-marker.js'
38
39
  import {
39
40
  ToolFlightTracker,
@@ -340,6 +341,7 @@ import {
340
341
  } from '../telegram-button-constraints.js'
341
342
  import {
342
343
  wrapAgentCallbacks,
344
+ redactAgentKeyboard,
343
345
  parseAgentCallback,
344
346
  extractAgentButtonMeta,
345
347
  keyboardIsSingleUse,
@@ -691,8 +693,8 @@ import {
691
693
  startSubagentWatcher,
692
694
  type SubagentWatcherHandle,
693
695
  } from '../subagent-watcher.js'
694
- import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
695
- import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
696
+ import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
697
+ import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
696
698
  import {
697
699
  startBootCard,
698
700
  resolvePersonaName,
@@ -5466,9 +5468,10 @@ const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
5466
5468
  // edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
5467
5469
  // HERE at the robustApiCall layer so every Bot API call routed through the
5468
5470
  // standard retry policy also transits one scheduler (no call site can bypass
5469
- // it). Feature-flagged, default OFF: when SWITCHROOM_TELEGRAM_SEND_GATE !== '1'
5470
- // the gate is a pure passthrough and the retry policy behaves exactly as
5471
- // before. Composes with #3094's pre-call flood gate and #3097's non-essential
5471
+ // it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
5472
+ // explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
5473
+ // policy behaves exactly as before. Composes with #3094's pre-call flood gate
5474
+ // and #3097's non-essential
5472
5475
  // drop policy — those decide whether a call happens at all; this paces the
5473
5476
  // calls that do.
5474
5477
  //
@@ -5527,6 +5530,65 @@ const swallowingApiCall = createSwallowingRetryApiCall(
5527
5530
  (line) => process.stderr.write(line),
5528
5531
  )
5529
5532
 
5533
+ /**
5534
+ * The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
5535
+ *
5536
+ * Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
5537
+ * `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
5538
+ * call sites fired reactions OUTSIDE the send gate AND the flood circuit
5539
+ * breaker, so:
5540
+ *
5541
+ * (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
5542
+ * turn's status-reaction churn added straight onto the per-bot flood
5543
+ * budget the REPLIES need; and
5544
+ * (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
5545
+ * is only learned through the retry module's `onFloodWait` hook, which a
5546
+ * raw `bot.api` call never reaches. Reactions were a residual flood
5547
+ * vector even with the gate ON.
5548
+ *
5549
+ * Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
5550
+ * the SAME path every other outbound call takes) with
5551
+ * `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
5552
+ * pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
5553
+ * keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
5554
+ * this returns the promise and does NOT swallow.
5555
+ */
5556
+ const gatedSetMessageReaction = (
5557
+ chatId: string,
5558
+ messageId: number,
5559
+ reaction: ReactionTypeEmoji[],
5560
+ ): Promise<unknown> =>
5561
+ robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
5562
+ chat_id: chatId,
5563
+ verb: 'set-message-reaction',
5564
+ priorityClass: 'cosmetic',
5565
+ })
5566
+
5567
+ /** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
5568
+ const sendReaction = (
5569
+ chatId: string,
5570
+ messageId: number,
5571
+ emoji: ReactionTypeEmoji['emoji'],
5572
+ ): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
5573
+
5574
+ /**
5575
+ * Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
5576
+ * routes through the send gate + flood breaker like every other reaction
5577
+ * (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
5578
+ * own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
5579
+ * must keep firing, which a swallowing wrapper would suppress.
5580
+ */
5581
+ const redactAuthCodeApi = {
5582
+ deleteMessage: (chatId: string, messageId: number) =>
5583
+ // allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
5584
+ bot.api.deleteMessage(chatId, messageId),
5585
+ setMessageReaction: (
5586
+ chatId: string,
5587
+ messageId: number,
5588
+ reaction: Array<{ type: 'emoji'; emoji: string }>,
5589
+ ) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
5590
+ }
5591
+
5530
5592
  /**
5531
5593
  * The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
5532
5594
  *
@@ -11724,10 +11786,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
11724
11786
 
11725
11787
  assertAllowedChat(chat_id)
11726
11788
 
11727
- const sent = await rawSendChecklist({
11728
- chat_id,
11789
+ // #2044 outbound secret scrub. Checklist title + task strings are
11790
+ // agent-authored free text sent to Telegram, same class as the reply
11791
+ // `text` path and ask_user — route both through the SAME redactor before
11792
+ // send (via the pure, unit-tested redactChecklistFields helper) so an
11793
+ // echoed token / DATABASE_URL is masked.
11794
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
11729
11795
  title,
11730
11796
  tasks,
11797
+ (t) => redactOutboundText(t, 'send_checklist'),
11798
+ )
11799
+
11800
+ const sent = await rawSendChecklist({
11801
+ chat_id,
11802
+ title: redactedTitle!,
11803
+ tasks: redactedTasks!,
11731
11804
  ...(threadId != null ? { message_thread_id: threadId } : {}),
11732
11805
  ...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
11733
11806
  ...(protectContent ? { protect_content: true } : {}),
@@ -11809,7 +11882,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
11809
11882
 
11810
11883
  assertAllowedChat(chat_id)
11811
11884
 
11812
- await rawEditMessageChecklist({ chat_id, message_id, title, tasks })
11885
+ // #2044 outbound secret scrub. update_checklist forwards the same
11886
+ // agent-authored title + task text to Telegram as send_checklist, so it
11887
+ // shares the identical leak class — redact both through the reply-path
11888
+ // redactor (via the shared redactChecklistFields helper) before the edit
11889
+ // lands. title / tasks may be undefined here (partial patch); the helper
11890
+ // passes undefined through untouched.
11891
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
11892
+ title,
11893
+ tasks,
11894
+ (t) => redactOutboundText(t, 'update_checklist'),
11895
+ )
11896
+
11897
+ await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
11813
11898
 
11814
11899
  process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
11815
11900
  return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
@@ -12537,8 +12622,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12537
12622
  .join('; ')
12538
12623
  throw new Error(`inline_keyboard validation failed: ${summary}`)
12539
12624
  }
12540
- replyButtonMeta = extractAgentButtonMeta(rawKeyboard)
12541
- replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
12625
+ // #3148 fast-follow: mask any secret an agent put in a visible button
12626
+ // `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
12627
+ // payload BEFORE the keyboard is sent — the same outbound scrub the reply
12628
+ // `text` body uses. `callback_data` (the routing key) is left exact.
12629
+ // Feeding BOTH the meta extraction and the callback wrap from the redacted
12630
+ // copy means the stashed toast, the tap echo (`button_text`), and the
12631
+ // "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
12632
+ const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
12633
+ redactOutboundText(s, 'reply_inline_keyboard'),
12634
+ )
12635
+ replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
12636
+ replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
12542
12637
  }
12543
12638
 
12544
12639
  // on-demand voice: append a single '🔊 Listen' button that synthesizes the
@@ -13509,6 +13604,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
13509
13604
  const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
13510
13605
  assertAllowedChat(args.chatId)
13511
13606
 
13607
+ // #2044 outbound secret scrub. The question text and each option/button
13608
+ // label are agent-authored free text sent to Telegram, exactly like the
13609
+ // reply `text` path — an agent that echoes a token or DATABASE_URL it just
13610
+ // read into a question or a button label would transmit it unmasked. Route
13611
+ // both through the SAME redactor as reply (via the pure, unit-tested
13612
+ // redactAskUserFields helper), assigning back onto the validated args so
13613
+ // every downstream consumer — the send, the timeout-edit re-render, and
13614
+ // the tap-echo of the chosen label stored in pendingAskUser — sees the
13615
+ // masked value.
13616
+ const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
13617
+ redactOutboundText(t, 'ask_user'),
13618
+ )
13619
+ args.question = scrubbed.question
13620
+ args.options = scrubbed.options
13621
+
13512
13622
  // Resolve thread + reply-to using the same auto-thread heuristic
13513
13623
  // executeReply uses, so an agent that omits message_thread_id still
13514
13624
  // routes into the right forum topic and quotes the user's last
@@ -14609,9 +14719,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
14609
14719
  if (!args.message_id) throw new Error('react: message_id is required')
14610
14720
  if (!args.emoji) throw new Error('react: emoji is required')
14611
14721
  assertAllowedChat(String(args.chat_id ?? ''))
14612
- await lockedBot.api.setMessageReaction(String(args.chat_id ?? ''), Number(args.message_id), [
14613
- { type: 'emoji', emoji: args.emoji as ReactionTypeEmoji['emoji'] },
14614
- ])
14722
+ await sendReaction(
14723
+ String(args.chat_id ?? ''),
14724
+ Number(args.message_id),
14725
+ args.emoji as ReactionTypeEmoji['emoji'],
14726
+ )
14615
14727
  return { content: [{ type: 'text', text: 'reacted' }] }
14616
14728
  }
14617
14729
 
@@ -17590,6 +17702,17 @@ function handleSessionEvent(ev: SessionEvent): void {
17590
17702
 
17591
17703
  // ─── PTY partial handler ─────────────────────────────────────────────────
17592
17704
  function handlePtyPartial(text: string): void {
17705
+ // #2044 outbound secret scrub. The PTY-tail partial is the assistant's
17706
+ // reply text extracted from Claude Code's TUI as it renders — an agent
17707
+ // that echoes a secret shows it here (in the live draft-preview stream)
17708
+ // BEFORE the reply tool ever fires, and this path forwards the text
17709
+ // straight to Telegram with no other redaction. Mask at the gateway
17710
+ // boundary through the SAME reply-path redactor so the draft stream, its
17711
+ // dedup key, and lastPtyPreviewByChat all compare redacted-against-
17712
+ // redacted — the identical in-place-mutation invariant the answer stream
17713
+ // relies on. Idempotent: replaying a buffered (already-masked) partial
17714
+ // re-masks to itself.
17715
+ text = redactOutboundText(text, 'pty_preview')
17593
17716
  // #1067: build the PtyHandlerState from a snapshot of currentTurn.
17594
17717
  // The pty-partial-handler module keeps its own state-shape contract
17595
17718
  // (currentSessionChatId / currentSessionThreadId) because it's
@@ -17624,6 +17747,13 @@ function handlePtyPartial(text: string): void {
17624
17747
  }
17625
17748
 
17626
17749
  function handlePtyActivity(text: string): void {
17750
+ // #2044 outbound secret scrub. PTY-activity is agent-derived free text
17751
+ // forwarded to Telegram via handleStreamReply — same leak class as the
17752
+ // draft preview above. Redact through the reply-path redactor before it
17753
+ // streams. (This handler is currently unwired in the gateway — the live
17754
+ // PTY path is handlePtyPartial — but the mask is kept here so the fix is
17755
+ // durable if the activity lane is ever re-armed.)
17756
+ text = redactOutboundText(text, 'pty_activity')
17627
17757
  // #1067: snapshot at entry. handleStreamReply is async and runs in
17628
17758
  // the background via void; the closure already captures `chatId` /
17629
17759
  // `threadId` locals, so the supersession is correctly scoped.
@@ -17976,9 +18106,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
17976
18106
  if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
17977
18107
  const access = loadAccess()
17978
18108
  if (!access.allowFrom.includes(String(from.id))) return
17979
- void bot.api.setMessageReaction(chatId, msgId, [
17980
- { type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
17981
- ]).catch(() => {})
18109
+ void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
17982
18110
  // #2527: log the early-ack fire so operators can see how often the
17983
18111
  // fast pre-coalesce DM path triggers vs. the controller path.
17984
18112
  logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
@@ -18219,9 +18347,7 @@ async function handleInbound(
18219
18347
  )
18220
18348
  if (inFlight) {
18221
18349
  if (msgId != null) {
18222
- void bot.api.setMessageReaction(chat_id, msgId, [
18223
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
18224
- ]).catch(() => {})
18350
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
18225
18351
  }
18226
18352
  await executeHaltNow('stop-keyword')
18227
18353
  }
@@ -18266,9 +18392,7 @@ async function handleInbound(
18266
18392
  `in_flight=${toolFlightTracker.inFlightCount()}\n`,
18267
18393
  )
18268
18394
  if (msgId != null) {
18269
- void bot.api.setMessageReaction(chat_id, msgId, [
18270
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
18271
- ]).catch(() => {})
18395
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
18272
18396
  }
18273
18397
  if (interrupt.emptyBody) {
18274
18398
  // #3020: empty `!` is a pure halt (no replacement body) — same shared
@@ -18402,9 +18526,7 @@ async function handleInbound(
18402
18526
  })
18403
18527
  if (msgId != null) {
18404
18528
  const emoji = behavior === 'allow' ? '✅' : '❌'
18405
- void bot.api.setMessageReaction(chat_id, msgId, [
18406
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
18407
- ]).catch(() => {})
18529
+ void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
18408
18530
  }
18409
18531
  return
18410
18532
  }
@@ -18460,7 +18582,7 @@ async function handleInbound(
18460
18582
  )
18461
18583
  }
18462
18584
  // Redact the OAuth code paste from chat history (#488).
18463
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18585
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18464
18586
  return
18465
18587
  }
18466
18588
  // Stale — drop the pending entry but let the message fall through
@@ -18492,7 +18614,7 @@ async function handleInbound(
18492
18614
  '_Still finishing the previous paste — one moment._',
18493
18615
  { html: true },
18494
18616
  )
18495
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18617
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18496
18618
  return
18497
18619
  }
18498
18620
  const result = await submitLoopbackRedirect(pendingLoop, text.trim())
@@ -18505,7 +18627,7 @@ async function handleInbound(
18505
18627
  { html: true },
18506
18628
  )
18507
18629
  // Redact the pasted redirect (carries the OAuth code) from history.
18508
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18630
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18509
18631
  return
18510
18632
  }
18511
18633
  if (result.retryable) {
@@ -18518,7 +18640,7 @@ async function handleInbound(
18518
18640
  { html: true },
18519
18641
  )
18520
18642
  // Redact even a rejected paste — it may still carry a live code.
18521
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18643
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18522
18644
  return
18523
18645
  }
18524
18646
  // Non-retryable — the flow is spent. Kill the CLI child before
@@ -18533,7 +18655,7 @@ async function handleInbound(
18533
18655
  `**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
18534
18656
  { html: true },
18535
18657
  )
18536
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18658
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18537
18659
  return
18538
18660
  }
18539
18661
  // Stale — the intercept window has closed. Kill the child and drop the
@@ -18553,7 +18675,7 @@ async function handleInbound(
18553
18675
  // reference AND a code/error param), so ordinary chatter mentioning
18554
18676
  // localhost flows through untouched. Redact and drop rather than forward.
18555
18677
  if (shouldConsumeLoopbackPaste(text)) {
18556
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18678
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18557
18679
  await switchroomReply(
18558
18680
  ctx,
18559
18681
  '_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
@@ -18588,7 +18710,7 @@ async function handleInbound(
18588
18710
  // Single-use code so a third party can't replay it after exchange,
18589
18711
  // but plaintext OAuth tokens in chat history are still poor
18590
18712
  // hygiene. The helper handles delete + 🔑 reaction silently.
18591
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18713
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18592
18714
  return
18593
18715
  }
18594
18716
  pendingReauthFlows.delete(interceptKey)
@@ -19051,12 +19173,12 @@ async function handleInbound(
19051
19173
  if (isSteering) {
19052
19174
  // Explicit steer: mark with 🤝 on the inbound message; leave the
19053
19175
  // existing StatusReactionController running for the in-flight turn.
19054
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '🤝' }]).catch(() => {})
19176
+ void sendReaction(chat_id, msgId, '🤝').catch(() => {})
19055
19177
  } else if (priorTurnInFlight) {
19056
19178
  // Queued mid-turn message (new default): don't touch the existing
19057
19179
  // controller; just ack the inbound message with 👀 so the user
19058
19180
  // knows we received it, without disrupting the in-flight reaction.
19059
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '👀' }]).catch(() => {})
19181
+ void sendReaction(chat_id, msgId, '👀').catch(() => {})
19060
19182
  // #203: time-to-ack metric — measure gateway-receive → ack-post delta.
19061
19183
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
19062
19184
  } else {
@@ -19099,9 +19221,7 @@ async function handleInbound(
19099
19221
  // msgId here and use it as the reaction-session token in log events.
19100
19222
  const ctrlTurnToken = `${chat_id}:${msgId}`
19101
19223
  const ctrl = new StatusReactionController(async (emoji) => {
19102
- await bot.api.setMessageReaction(chat_id, msgId, [
19103
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
19104
- ])
19224
+ await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
19105
19225
  // #203: every status-reaction transition is a user-visible signal.
19106
19226
  signalTracker.noteSignal(key, Date.now())
19107
19227
  }, allowedReactions, {
@@ -19184,9 +19304,7 @@ async function handleInbound(
19184
19304
  }
19185
19305
  }
19186
19306
  } else if (access.ackReaction) {
19187
- void bot.api.setMessageReaction(chat_id, msgId, [
19188
- { type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
19189
- ]).catch(() => {})
19307
+ void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
19190
19308
  // #203: time-to-ack metric for the custom-ack-reaction path.
19191
19309
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
19192
19310
  }
@@ -20529,7 +20647,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
20529
20647
  try {
20530
20648
  await sweepActiveReactions(
20531
20649
  agentDir,
20532
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
20650
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
20533
20651
  { log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
20534
20652
  )
20535
20653
  } catch (err) {
@@ -24905,7 +25023,18 @@ bot.command('usage', async ctx => {
24905
25023
  // /auth snapshot does. switchroomReply routes through the rich path
24906
25024
  // (replyWithRichMessage), which accepts reply_markup. Build a grammy
24907
25025
  // InlineKeyboard so the markup type matches switchroomReply's contract.
24908
- const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25026
+ // Defense-in-depth (mirrors the operator-private `/auth` treatment,
25027
+ // gateway.ts ~24036): outside a private chat, strip the
25028
+ // `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
25029
+ // swap button is never even offered in a group/forum. The dispatch-
25030
+ // site allowFrom gate is the load-bearing control; this just avoids
25031
+ // dangling a privileged button in front of non-operators.
25032
+ let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25033
+ if (ctx.chat?.type !== 'private') {
25034
+ kbRows = kbRows.filter(
25035
+ (row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
25036
+ )
25037
+ }
24909
25038
  const keyboard = new InlineKeyboard()
24910
25039
  kbRows.forEach((row, ri) => {
24911
25040
  if (ri > 0) keyboard.row()
@@ -25033,7 +25162,19 @@ bot.on('callback_query:data', async ctx => {
25033
25162
  // Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
25034
25163
  // through a dedicated handler that maps each action onto the
25035
25164
  // existing CLI invocations plus dashboard refresh.
25165
+ //
25166
+ // Strict allowFrom gate like every other mutating callback family
25167
+ // (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
25168
+ // `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
25169
+ // OAuth account swap — on an admin forum/supergroup agent with an empty
25170
+ // group allowFrom, any member could tap it and swap the active account.
25036
25171
  if (data.startsWith('auth:')) {
25172
+ const access = loadAccess()
25173
+ const senderId = String(ctx.from?.id ?? '')
25174
+ if (!access.allowFrom.includes(senderId)) {
25175
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
25176
+ return
25177
+ }
25037
25178
  await handleAuthDashboardCallback(ctx)
25038
25179
  return
25039
25180
  }
@@ -26735,9 +26876,7 @@ async function handleAckOnly(
26735
26876
  const chat_id = String(ctx.chat!.id)
26736
26877
  const msgId = ctx.message?.message_id
26737
26878
  if (msgId != null) {
26738
- void bot.api.setMessageReaction(chat_id, msgId, [
26739
- { type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
26740
- ]).catch(() => {})
26879
+ void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
26741
26880
  }
26742
26881
  const prefix = opts.warn ? 'WARN ' : ''
26743
26882
  process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
@@ -26770,9 +26909,7 @@ async function handleRefusal(
26770
26909
  const msgId = ctx.message?.message_id
26771
26910
  const messageThreadId = ctx.message?.message_thread_id
26772
26911
  if (msgId != null) {
26773
- void bot.api.setMessageReaction(chat_id, msgId, [
26774
- { type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
26775
- ]).catch(() => {})
26912
+ void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
26776
26913
  }
26777
26914
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
26778
26915
  // deleted topic doesn't crash the refusal handler.
@@ -27831,7 +27968,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
27831
27968
  if (startupAgentDir != null) {
27832
27969
  void sweepActiveReactions(
27833
27970
  startupAgentDir,
27834
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
27971
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
27835
27972
  { log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
27836
27973
  )
27837
27974
  }
@@ -28740,30 +28877,41 @@ void (async () => {
28740
28877
  // Best-effort: a registry read failure (e.g. no worktree dir
28741
28878
  // on an agent that never claims one) must not affect the
28742
28879
  // primary agentCwd watch.
28743
- extraWatchCwdsProvider: () =>
28744
- // Fail-CLOSED ownership filter (unset identity nothing;
28745
- // ownerless records excluded; registry throw []). Extracted
28746
- // to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
28747
- // Gap-2 ownership predicate is under direct unit test — see
28748
- // telegram-plugin/tests/worktree-watch-cwds.test.ts.
28749
- ownedWorktreeCwds({
28750
- self: process.env.SWITCHROOM_AGENT_NAME,
28751
- listRecords: listWorktreeRecords,
28752
- // Durable, non-env identity fallback (#1116 / #2893): when
28753
- // SWITCHROOM_AGENT_NAME is somehow unset, derive this
28754
- // agent's own identity from its own directory so worktree
28755
- // ownership still resolves (env is only the fast path).
28756
- // `watcherAgentDir` is guaranteed non-null in this branch
28757
- // (the whole watcher is gated on it above). Kill-switch
28758
- // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
28759
- // pre-fix env-only behaviour.
28760
- agentDir:
28761
- process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
28762
- ? undefined
28763
- : watcherAgentDir,
28764
- log: (msg) =>
28765
- process.stderr.write(`telegram gateway: ${msg}\n`),
28766
- }),
28880
+ // On every ~1s rescan tick the provider does BOTH:
28881
+ // 1. advances the heartbeat of every worktree THIS agent owns
28882
+ // the F1/H3 production driver that keeps `touchHeartbeat`
28883
+ // alive (it had ZERO callers, so every claim read "stale"
28884
+ // 10 min after creation and the reaper's staleness
28885
+ // guarantee collapsed); throttled to ≤1 write / 2 min per
28886
+ // record, and
28887
+ // 2. returns the fail-CLOSED set of owned worktree cwds for the
28888
+ // watcher to also watch (#1116 / Gap-2 ownership predicate).
28889
+ // Extracted to telegram-plugin/worktree-watch-cwds.ts as
28890
+ // `makeWorktreeWatchProvider` so the wiring specifically that
28891
+ // the provider ACTUALLY drives heartbeats, not just cwds — is
28892
+ // under direct unit test (see the provider behaviour test in
28893
+ // telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
28894
+ // best-effort: it never throws out of the provider.
28895
+ extraWatchCwdsProvider: makeWorktreeWatchProvider({
28896
+ self: process.env.SWITCHROOM_AGENT_NAME,
28897
+ // Durable, non-env identity fallback (#1116 / #2893): when
28898
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
28899
+ // own identity from its own directory so worktree ownership
28900
+ // still resolves (env is only the fast path). `watcherAgentDir`
28901
+ // is guaranteed non-null in this branch (the whole watcher is
28902
+ // gated on it above). Kill-switch
28903
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
28904
+ // env-only behaviour. One `agentDir` governs BOTH the heartbeat
28905
+ // refresh and the cwd derivation.
28906
+ agentDir:
28907
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
28908
+ ? undefined
28909
+ : watcherAgentDir,
28910
+ listRecords: listWorktreeRecords,
28911
+ touchHeartbeat: touchWorktreeHeartbeat,
28912
+ log: (msg) =>
28913
+ process.stderr.write(`telegram gateway: ${msg}\n`),
28914
+ }),
28767
28915
  // Bug 0 fix: previously omitted, leaving the watcher unable to
28768
28916
  // write liveness/stall/turn_end updates to the registry DB.
28769
28917
  // Liveness writes are now persisted across the gateway lifetime.
@@ -178,6 +178,57 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
178
178
  }
179
179
  }
180
180
 
181
+ // Migration (review finding H5): make INSERT OR REPLACE idempotent for thread-less rows.
182
+ //
183
+ // The table's PRIMARY KEY is (chat_id, thread_id, message_id), but thread_id
184
+ // is nullable and SQLite treats NULL as DISTINCT from NULL in a PK/UNIQUE
185
+ // index. Every DM and every non-topic group message has thread_id = NULL, so
186
+ // two inserts of the same (chat_id, message_id) with NULL thread do NOT
187
+ // conflict — `INSERT OR REPLACE` APPENDS a duplicate row instead of replacing
188
+ // it. On the documented at-least-once boot replay / synthesized-resume
189
+ // re-record, an already-stored message is duplicated, inflating
190
+ // get_recent_messages and the getRecentOutboundCount / hasOutboundDeliveredSince
191
+ // counters that feed the silence / over-ping detectors.
192
+ //
193
+ // Fix: a UNIQUE index over COALESCE(thread_id, '') gives every logical
194
+ // (chat, thread-or-general, message_id) key a NON-null uniqueness value, so
195
+ // REPLACE conflict-resolves and dedupes thread-less rows too. INSERT OR
196
+ // REPLACE resolves against ANY unique index, so no writer change is needed.
197
+ // A forum-topic row (non-null thread) and a general row (NULL thread) that
198
+ // share a message_id keep DISTINCT keys (the topic id vs ''), so they stay
199
+ // separate. Stored thread_id values remain real NULLs, so every read path
200
+ // (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
201
+ const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
202
+ const logicalKeyIndexExists =
203
+ db
204
+ .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
205
+ .get(LOGICAL_KEY_INDEX) != null
206
+ if (!logicalKeyIndexExists) {
207
+ // De-dupe rows an earlier (pre-fix) build already appended, keeping the
208
+ // NEWEST row per logical key (highest ts, then highest rowid = the last
209
+ // write, which is what INSERT OR REPLACE would have left). This MUST run
210
+ // before the UNIQUE index is created, or CREATE UNIQUE INDEX would fail on
211
+ // the existing duplicates. On a fresh/empty DB it is a harmless no-op.
212
+ db.exec(`
213
+ DELETE FROM messages
214
+ WHERE rowid NOT IN (
215
+ SELECT keep_rowid FROM (
216
+ SELECT rowid AS keep_rowid,
217
+ ROW_NUMBER() OVER (
218
+ PARTITION BY chat_id, COALESCE(thread_id, ''), message_id
219
+ ORDER BY ts DESC, rowid DESC
220
+ ) AS rn
221
+ FROM messages
222
+ )
223
+ WHERE rn = 1
224
+ )
225
+ `)
226
+ db.exec(
227
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${LOGICAL_KEY_INDEX} ` +
228
+ `ON messages (chat_id, COALESCE(thread_id, ''), message_id)`,
229
+ )
230
+ }
231
+
181
232
  // Readable by owner and others so the web dashboard (different uid than the
182
233
  // agent) can stream replies back to Hermes Desktop. The WAL sidecar files
183
234
  // (-shm/-wal) are also chmod'd so SQLite readonly opens succeed for uid=1000.