switchroom 0.18.12 → 0.18.14

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -14,6 +14,7 @@ import {
14
14
  AGENT_CALLBACK_PREFIX,
15
15
  AGENT_CALLBACK_DATA_MAX,
16
16
  wrapAgentCallbacks,
17
+ redactAgentKeyboard,
17
18
  parseAgentCallback,
18
19
  validateAndWrapAgentKeyboard,
19
20
  extractAgentButtonMeta,
@@ -22,6 +23,7 @@ import {
22
23
  escapeHtmlEntities,
23
24
  applyTapAnnotationEdit,
24
25
  } from '../inline-keyboard-callbacks.js'
26
+ import { redact } from '../secret-detect/redact.js'
25
27
 
26
28
  describe('inline-keyboard-callbacks (#271)', () => {
27
29
  describe('AGENT_CALLBACK_DATA_MAX', () => {
@@ -431,4 +433,166 @@ describe('inline-keyboard-callbacks (#271)', () => {
431
433
  expect(outcome).toBe('failed')
432
434
  })
433
435
  })
436
+
437
+ // ─── #3148 fast-follow: agent-authored keyboard secret scrub ──────────────
438
+ //
439
+ // wrapAgentCallbacks rewrites ONLY callback_data; the visible `text` label,
440
+ // the `ack_text` toast, and any `copy_text.text` clipboard payload passed
441
+ // through VERBATIM before this fix — so a secret an agent placed in any of
442
+ // them transmitted to Telegram unmasked and resurfaced on tap (echo, toast,
443
+ // "✅ You chose" annotation). redactAgentKeyboard masks those three fields at
444
+ // the outbound boundary using the SAME redact() the reply body uses, while
445
+ // leaving the routing key (callback_data) exact.
446
+ describe('redactAgentKeyboard (#3148 fast-follow)', () => {
447
+ // Assemble the fixture at runtime so this source file never carries a
448
+ // contiguous token that trips push-protection / secretlint (CLAUDE.md).
449
+ const GITHUB_PAT = 'ghp' + '_' + '16C7e42F292c6912E7710c838347Ae178B4a'
450
+
451
+ it('masks a secret in a button `text` label in the sent payload (real redact)', () => {
452
+ const raw = [
453
+ [{ text: `Use ${GITHUB_PAT}`, callback_data: 'use_token' }],
454
+ ]
455
+ // Mirror the gateway pipeline: redact BEFORE wrap.
456
+ const wrapped = wrapAgentCallbacks(redactAgentKeyboard(raw, redact))
457
+ const btn = wrapped[0]![0]!
458
+ // OUTCOME: the label bytes actually shipped to Telegram carry no secret.
459
+ expect(btn.text as string).not.toContain(GITHUB_PAT)
460
+ expect(btn.text as string).toContain('[REDACTED')
461
+ expect((btn.text as string).length).toBeGreaterThan(0)
462
+ // Routing key untouched (only the agent: namespace prefix was added).
463
+ expect(btn.callback_data).toBe(`${AGENT_CALLBACK_PREFIX}use_token`)
464
+ })
465
+
466
+ it('masks a secret in `ack_text` in the extracted per-button meta (real redact)', () => {
467
+ const raw = [
468
+ [{ text: 'Deploy', callback_data: 'deploy', ack_text: `token ${GITHUB_PAT}` }],
469
+ ]
470
+ // Mirror the gateway: extract meta from the REDACTED keyboard so the
471
+ // stashed toast (shown via answerCallbackQuery on tap) is masked.
472
+ const meta = extractAgentButtonMeta(redactAgentKeyboard(raw, redact))
473
+ const ack = meta.get('deploy')!.ack_text!
474
+ expect(ack).not.toContain(GITHUB_PAT)
475
+ expect(ack).toContain('[REDACTED')
476
+ })
477
+
478
+ it('masks a secret in `copy_text.text` clipboard payload (real redact)', () => {
479
+ const raw = [
480
+ [{ text: 'Copy token', callback_data: 'copy', copy_text: { text: GITHUB_PAT } }],
481
+ ]
482
+ const [[btn]] = redactAgentKeyboard(raw, redact)
483
+ const ct = (btn as { copy_text: { text: string } }).copy_text.text
484
+ expect(ct).not.toContain(GITHUB_PAT)
485
+ expect(ct).toContain('[REDACTED')
486
+ })
487
+
488
+ it('never empties a label that is ENTIRELY a secret (non-empty invariant)', () => {
489
+ const raw = [[{ text: GITHUB_PAT, callback_data: 'x' }]]
490
+ const [[btn]] = redactAgentKeyboard(raw, redact)
491
+ expect((btn.text as string).length).toBeGreaterThan(0)
492
+ expect(btn.text as string).not.toContain(GITHUB_PAT)
493
+ })
494
+
495
+ it('leaves a legit (secret-free) label unchanged', () => {
496
+ const raw = [
497
+ [{ text: 'Approve PR #547', callback_data: 'approve', ack_text: 'Approved ✓' }],
498
+ [{ text: 'Hold', callback_data: 'hold' }],
499
+ ]
500
+ const out = redactAgentKeyboard(raw, redact)
501
+ expect(out[0]![0]!.text).toBe('Approve PR #547')
502
+ expect(out[0]![0]!.ack_text).toBe('Approved ✓')
503
+ expect(out[1]![0]!.text).toBe('Hold')
504
+ })
505
+
506
+ it('NEVER passes callback_data through the redactor (routing key stays exact)', () => {
507
+ // A fake redactor that mangles everything it sees; anything the routing
508
+ // key touched would be corrupted. Proves copy_text/url/callback_data are
509
+ // handled correctly by field, not blanket-scrubbed.
510
+ const seen: string[] = []
511
+ const mangle = (s: string) => {
512
+ seen.push(s)
513
+ return `X${s}X`
514
+ }
515
+ const raw = [
516
+ [
517
+ { text: 'Label', callback_data: 'route_key_do_not_touch', ack_text: 'toast' },
518
+ { text: 'Link', url: 'https://example.com/keep' },
519
+ { text: 'Clip', callback_data: 'c2', copy_text: { text: 'secret-clip' } },
520
+ ],
521
+ ]
522
+ const out = redactAgentKeyboard(raw, mangle)
523
+ // callback_data + url are byte-exact.
524
+ expect(out[0]![0]!.callback_data).toBe('route_key_do_not_touch')
525
+ expect(out[0]![1]!.url).toBe('https://example.com/keep')
526
+ expect(out[0]![2]!.callback_data).toBe('c2')
527
+ // Only free-text fields were routed through the redactor.
528
+ expect(out[0]![0]!.text).toBe('XLabelX')
529
+ expect(out[0]![0]!.ack_text).toBe('XtoastX')
530
+ expect((out[0]![2]! as { copy_text: { text: string } }).copy_text.text).toBe('Xsecret-clipX')
531
+ expect(seen).toEqual(['Label', 'toast', 'Link', 'Clip', 'secret-clip'])
532
+ expect(seen).not.toContain('route_key_do_not_touch')
533
+ expect(seen).not.toContain('https://example.com/keep')
534
+ expect(seen).not.toContain('c2')
535
+ })
536
+
537
+ it('masks a secret in switch_inline_query* (pasted into the chat input on tap)', () => {
538
+ const raw = [
539
+ [{ text: 'Share', callback_data: 's', switch_inline_query: `key ${GITHUB_PAT}` }],
540
+ [{
541
+ text: 'Fill here',
542
+ callback_data: 'f',
543
+ switch_inline_query_current_chat: `key ${GITHUB_PAT}`,
544
+ }],
545
+ ]
546
+ const out = redactAgentKeyboard(raw, redact)
547
+ const siq = (out[0]![0]! as { switch_inline_query: string }).switch_inline_query
548
+ const siqc = (out[1]![0]! as { switch_inline_query_current_chat: string })
549
+ .switch_inline_query_current_chat
550
+ expect(siq).not.toContain(GITHUB_PAT)
551
+ expect(siq).toContain('[REDACTED')
552
+ expect(siqc).not.toContain(GITHUB_PAT)
553
+ expect(siqc).toContain('[REDACTED')
554
+ })
555
+
556
+ it('masks a secret in switch_inline_query_chosen_chat.query', () => {
557
+ const raw = [[{
558
+ text: 'Share to…',
559
+ callback_data: 'cc',
560
+ switch_inline_query_chosen_chat: { query: `key ${GITHUB_PAT}`, allow_user_chats: true },
561
+ }]]
562
+ const [[btn]] = redactAgentKeyboard(raw, redact)
563
+ const cc = (btn as { switch_inline_query_chosen_chat: { query: string; allow_user_chats?: boolean } })
564
+ .switch_inline_query_chosen_chat
565
+ expect(cc.query).not.toContain(GITHUB_PAT)
566
+ expect(cc.query).toContain('[REDACTED')
567
+ // sibling sub-fields are preserved
568
+ expect(cc.allow_user_chats).toBe(true)
569
+ })
570
+
571
+ it('clamps a masked field that the marker pushed over the Telegram cap (reply not dropped)', () => {
572
+ // A 60-char label (< 64 cap) whose short secret expands under the marker
573
+ // would exceed 64 and 400-drop the whole reply; the clamp keeps it ≤ cap.
574
+ const shortSecret = 'AKIAIOSFODNN7EXAMPLE' // 20 chars, AWS-key shaped
575
+ const label = `${shortSecret} ${'x'.repeat(43)}` // 64 chars total, at the cap
576
+ const raw = [[{ text: label, callback_data: 'x' }]]
577
+ const [[btn]] = redactAgentKeyboard(raw, redact)
578
+ const text = btn.text as string
579
+ expect(text.length).toBeLessThanOrEqual(64)
580
+ expect(text).not.toContain(shortSecret)
581
+ })
582
+
583
+ it('preserves keyboard structure, row/column order, and does not mutate input', () => {
584
+ const raw = [
585
+ [{ text: 'A', callback_data: 'a' }, { text: 'B', callback_data: 'b' }],
586
+ [{ text: 'C', callback_data: 'c' }],
587
+ ]
588
+ const out = redactAgentKeyboard(raw, (s) => s)
589
+ expect(out.map((r) => r.map((b) => b.callback_data))).toEqual([
590
+ ['a', 'b'],
591
+ ['c'],
592
+ ])
593
+ // Fresh objects — input untouched.
594
+ expect(out[0]![0]).not.toBe(raw[0]![0])
595
+ expect(raw[0]![0]!.text).toBe('A')
596
+ })
597
+ })
434
598
  })
@@ -9,6 +9,7 @@ import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { detectErrorInTranscriptLine, startSessionTail } from '../session-tail.js'
11
11
  import { resetAllCooldowns } from '../operator-events.js'
12
+ import { resolveModelUnavailableFromOperatorEvent } from '../model-unavailable.js'
12
13
 
13
14
  // ─── detectErrorInTranscriptLine unit tests ───────────────────────────────────
14
15
 
@@ -155,6 +156,79 @@ describe('detectErrorInTranscriptLine — error detection', () => {
155
156
  expect(result!.detail).toContain('hit your limit')
156
157
  })
157
158
 
159
+ // Regression — the carrie incident (2026-07-12). Anthropic also emits a
160
+ // 429 for a TRANSIENT per-account burst / RPM throttle whose wording
161
+ // explicitly negates the account-quota reading ("would exceed your
162
+ // account's rate limit … not your usage limit"). That is a self-healing
163
+ // few-second throttle Claude Code retries internally — it must NOT be
164
+ // labeled quota-exhausted (which always shows the scary "model
165
+ // unavailable" card + drives failover). It must take the calm
166
+ // rate-limited path.
167
+ it('classifies a TRANSIENT burst 429 (explicit negation) as rate-limited, NOT quota-exhausted', () => {
168
+ const line = JSON.stringify({
169
+ type: 'assistant',
170
+ message: {
171
+ role: 'assistant',
172
+ model: '<synthetic>',
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text:
177
+ 'API Error: 429 rate_limit_error This request would exceed ' +
178
+ "your account's rate limit. Please try again later. This is a " +
179
+ 'short-term burst limit, not your usage limit.',
180
+ },
181
+ ],
182
+ },
183
+ error: 'rate_limit',
184
+ isApiErrorMessage: true,
185
+ apiErrorStatus: 429,
186
+ })
187
+ const result = detectErrorInTranscriptLine(line)
188
+ expect(result).not.toBeNull()
189
+ // Wording-based classification: explicit transient negation → calm path.
190
+ expect(result!.kind).toBe('rate-limited')
191
+ expect(result!.transient).toBe(true)
192
+ // End-to-end: the resolver must NOT produce a model-unavailable card for
193
+ // this kind (the calm rate-limited branch returns null on a bare burst).
194
+ const detection = resolveModelUnavailableFromOperatorEvent({
195
+ kind: result!.kind,
196
+ detail: result!.detail,
197
+ })
198
+ expect(detection).toBeNull()
199
+ })
200
+
201
+ // Guard against over-correcting: a GENUINE quota wall (no transient marker)
202
+ // must STILL be quota-exhausted AND still resolve to a card.
203
+ it('a genuine quota-wall 429 still produces the quota-exhausted card', () => {
204
+ const line = JSON.stringify({
205
+ type: 'assistant',
206
+ message: {
207
+ role: 'assistant',
208
+ model: '<synthetic>',
209
+ content: [
210
+ {
211
+ type: 'text',
212
+ text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
213
+ },
214
+ ],
215
+ },
216
+ error: 'rate_limit',
217
+ isApiErrorMessage: true,
218
+ apiErrorStatus: 429,
219
+ })
220
+ const result = detectErrorInTranscriptLine(line)
221
+ expect(result).not.toBeNull()
222
+ expect(result!.kind).toBe('quota-exhausted')
223
+ // The quota-exhausted branch ALWAYS returns a detection (the card).
224
+ const detection = resolveModelUnavailableFromOperatorEvent({
225
+ kind: result!.kind,
226
+ detail: result!.detail,
227
+ })
228
+ expect(detection).not.toBeNull()
229
+ expect(detection!.kind).toBe('quota_exhausted')
230
+ })
231
+
158
232
  it('still returns null for a normal (non-error) assistant message', () => {
159
233
  // No isApiErrorMessage flag → must NOT be treated as an error.
160
234
  const line = JSON.stringify({
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ redactAskUserFields,
4
+ redactChecklistFields,
5
+ } from '../outbound-field-redact.js'
6
+ import { redact } from '../secret-detect/redact.js'
7
+
8
+ /**
9
+ * OUTCOME tests for the #2044 structured-payload outbound scrub (F1/F2).
10
+ *
11
+ * These exercise the exact helpers the gateway calls (redactAskUserFields
12
+ * for ask_user, redactChecklistFields for send_checklist / update_checklist),
13
+ * injecting the SAME real `redact()` the gateway's redactOutboundText wraps.
14
+ * The assertion is on the SENT PAYLOAD the helper returns — a secret echoed
15
+ * into a question, an option/button label, a checklist title, or a task's
16
+ * text must be masked (marker present, raw token absent) — not merely that a
17
+ * redactor was invoked.
18
+ *
19
+ * Secrets are assembled at runtime (per CLAUDE.md "Secrets in tests") so the
20
+ * source file never contains a contiguous token that trips push-protection.
21
+ */
22
+
23
+ // A realistic GitHub classic PAT shape (ghp_ + 36 chars) — high-confidence,
24
+ // masked by redact() to a [REDACTED:github_pat_classic] marker.
25
+ const SECRET = 'ghp' + '_' + 'AbCdEfGhIj0123456789KlMnOpQrStUvWxYz'
26
+
27
+ describe('redactAskUserFields — ask_user outbound scrub (F1)', () => {
28
+ it('masks a secret echoed into the QUESTION text', () => {
29
+ const out = redactAskUserFields(
30
+ `Deploy with token ${SECRET}?`,
31
+ ['Yes', 'No'],
32
+ redact,
33
+ )
34
+ expect(out.question).not.toContain(SECRET)
35
+ expect(out.question).toContain('[REDACTED')
36
+ // Non-secret prose is preserved.
37
+ expect(out.question).toContain('Deploy with token')
38
+ })
39
+
40
+ it('masks a secret echoed into an OPTION / button label', () => {
41
+ const out = redactAskUserFields(
42
+ 'Which key?',
43
+ ['Cancel', `Use ${SECRET}`],
44
+ redact,
45
+ )
46
+ expect(out.options[0]).toBe('Cancel')
47
+ expect(out.options[1]).not.toContain(SECRET)
48
+ expect(out.options[1]).toContain('[REDACTED')
49
+ })
50
+
51
+ it('leaves clean question + options untouched', () => {
52
+ const out = redactAskUserFields('Proceed?', ['Yes', 'No'], redact)
53
+ expect(out.question).toBe('Proceed?')
54
+ expect(out.options).toEqual(['Yes', 'No'])
55
+ })
56
+
57
+ it('does not mutate the caller-supplied options array', () => {
58
+ const options = ['Cancel', `Use ${SECRET}`]
59
+ redactAskUserFields('q', options, redact)
60
+ expect(options[1]).toBe(`Use ${SECRET}`) // original array untouched
61
+ })
62
+ })
63
+
64
+ describe('redactChecklistFields — checklist outbound scrub (F2)', () => {
65
+ it('masks a secret in the checklist TITLE', () => {
66
+ const out = redactChecklistFields(
67
+ `Rotate ${SECRET}`,
68
+ [{ text: 'step one' }],
69
+ redact,
70
+ )
71
+ expect(out.title).not.toContain(SECRET)
72
+ expect(out.title).toContain('[REDACTED')
73
+ })
74
+
75
+ it('masks a secret in a TASK text and preserves other task fields', () => {
76
+ const out = redactChecklistFields(
77
+ 'Onboarding',
78
+ [
79
+ { text: 'read the docs', done: true },
80
+ { text: `save ${SECRET} to vault`, id: '7' },
81
+ ],
82
+ redact,
83
+ )
84
+ expect(out.tasks![0]).toEqual({ text: 'read the docs', done: true })
85
+ expect(out.tasks![1]!.text).not.toContain(SECRET)
86
+ expect(out.tasks![1]!.text).toContain('[REDACTED')
87
+ // Sibling fields on the redacted task are carried through untouched.
88
+ expect(out.tasks![1]!.id).toBe('7')
89
+ })
90
+
91
+ it('passes undefined title / tasks through (update_checklist partial patch)', () => {
92
+ const out = redactChecklistFields(undefined, undefined, redact)
93
+ expect(out.title).toBeUndefined()
94
+ expect(out.tasks).toBeUndefined()
95
+ })
96
+
97
+ it('leaves an id-only task (no text) untouched', () => {
98
+ const out = redactChecklistFields(undefined, [{ id: '3', done: true }], redact)
99
+ expect(out.tasks![0]).toEqual({ id: '3', done: true })
100
+ })
101
+
102
+ it('does not mutate the caller-supplied task objects', () => {
103
+ const tasks = [{ text: `save ${SECRET}` }]
104
+ redactChecklistFields('t', tasks, redact)
105
+ expect(tasks[0]!.text).toBe(`save ${SECRET}`) // original untouched
106
+ })
107
+ })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * #3155 — `setMessageReaction` must route through the send gate + flood breaker.
3
+ *
4
+ * Before this change ~13 raw `bot.api.setMessageReaction(...)` /
5
+ * `lockedBot.api.setMessageReaction(...)` call sites (early-ack 👀, status
6
+ * reactions, stop/interrupt ⚡, permission verdict ✅/❌, refusal 🚫, ack-only,
7
+ * the MCP `react` tool, the crash-recovery sweeps, the auth-code 🔑 redact)
8
+ * fired reactions OUTSIDE both:
9
+ *
10
+ * (a) the send gate — so reaction churn was never paced/shed under flood
11
+ * pressure even with the gate ON; and
12
+ * (b) the flood circuit breaker — a 429 from a reaction never reached the
13
+ * retry module's `onFloodWait` hook, so the ban went unrecorded.
14
+ *
15
+ * Reactions were therefore a residual flood vector even when the gate is on.
16
+ * The gateway now funnels every reaction through ONE seam,
17
+ * `robustApiCall(() => lockedBot.api.setMessageReaction(...), { priorityClass:
18
+ * 'cosmetic', verb: 'set-message-reaction' })`.
19
+ *
20
+ * Two levels of coverage:
21
+ * 1. BEHAVIOURAL — compose the real `send-gate` + `retry-api-call` modules the
22
+ * same way `gateway.ts` wires `robustApiCall`, and prove a cosmetic
23
+ * reaction is (a) shed when a flood window is open and (b) visible to
24
+ * `onFloodWait` on a 429.
25
+ * 2. LOAD-BEARING SOURCE SCAN — assert the gateway has exactly ONE runtime
26
+ * `setMessageReaction` call and that it is the cosmetic seam. This FAILS if
27
+ * any future change re-introduces a raw (ungated, breaker-blind) reaction.
28
+ */
29
+ import { describe, it, expect, vi } from 'vitest'
30
+ import { readFileSync } from 'node:fs'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { createSendGate, type Clock } from '../send-gate.js'
33
+ import { createRetryApiCall } from '../retry-api-call.js'
34
+ import { errors } from './fake-bot-api.js'
35
+
36
+ const GATEWAY_SRC = fileURLToPath(new URL('../gateway/gateway.ts', import.meta.url))
37
+
38
+ /** Fixed-time clock; `sleep` is a no-op so nothing actually waits under test. */
39
+ function fixedClock(now: number): Clock {
40
+ return { now: () => now, sleep: () => Promise.resolve() }
41
+ }
42
+
43
+ /**
44
+ * Reconstruct the gateway's outbound wiring for reactions: `robustApiCall =
45
+ * gate(rawRetry)`, and a `sendReaction` closure that tags the call `cosmetic`
46
+ * exactly like `gatedSetMessageReaction` in gateway.ts.
47
+ */
48
+ function wireReactionPath(opts: {
49
+ clock: Clock
50
+ enabled: boolean
51
+ windowUntilTs?: number
52
+ onFloodWait?: (retryAfterSec: number) => void
53
+ }) {
54
+ const setMessageReaction = vi.fn(async () => true as const)
55
+ const sendGate = createSendGate({
56
+ enabled: opts.enabled,
57
+ clock: opts.clock,
58
+ ...(opts.windowUntilTs != null
59
+ ? { initialWindows: [{ scopeKey: 'global', untilTs: opts.windowUntilTs }] }
60
+ : {}),
61
+ })
62
+ const rawRetry = createRetryApiCall({
63
+ maxRetries: 2,
64
+ sleep: async () => {},
65
+ ...(opts.onFloodWait ? { onFloodWait: opts.onFloodWait } : {}),
66
+ })
67
+ const robustApiCall = <T>(
68
+ fn: () => Promise<T>,
69
+ o?: Parameters<typeof rawRetry<T>>[1],
70
+ ): Promise<T> => sendGate.gate(() => rawRetry(fn, o), o)
71
+
72
+ // Mirrors gateway.ts `gatedSetMessageReaction` / `sendReaction`.
73
+ const sendReaction = (chatId: string, messageId: number, emoji: string): Promise<unknown> =>
74
+ robustApiCall(() => setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]), {
75
+ chat_id: chatId,
76
+ verb: 'set-message-reaction',
77
+ priorityClass: 'cosmetic',
78
+ })
79
+
80
+ return { sendReaction, setMessageReaction, sendGate }
81
+ }
82
+
83
+ describe('#3155 reactions route through the send gate (cosmetic)', () => {
84
+ it('SHEDS a reaction while a flood window is open (paced/shed by the gate)', async () => {
85
+ const now = 1_000_000
86
+ const { sendReaction, setMessageReaction, sendGate } = wireReactionPath({
87
+ clock: fixedClock(now),
88
+ enabled: true,
89
+ windowUntilTs: now + 60_000, // an open global ban window
90
+ })
91
+
92
+ const result = await sendReaction('chatA', 42, '👀')
93
+
94
+ // Cosmetic + a covering window ⇒ shed: the API is NEVER hit, the promise
95
+ // resolves undefined (fire-and-forget callers .catch nothing), and the gate
96
+ // counts the shed.
97
+ expect(setMessageReaction).not.toHaveBeenCalled()
98
+ expect(result).toBeUndefined()
99
+ expect(sendGate.stats().global.shed).toBe(1)
100
+ })
101
+
102
+ it('does NOT shed when the gate is OFF (proves the gate is what sheds)', async () => {
103
+ const now = 1_000_000
104
+ const { sendReaction, setMessageReaction } = wireReactionPath({
105
+ clock: fixedClock(now),
106
+ enabled: false, // gate disabled ⇒ pure passthrough
107
+ windowUntilTs: now + 60_000,
108
+ })
109
+
110
+ await sendReaction('chatA', 42, '👀')
111
+
112
+ // Flag OFF ⇒ the reaction still fires (the window only bites when enabled).
113
+ expect(setMessageReaction).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('records a 429 on a reaction to the flood breaker via onFloodWait', async () => {
117
+ const now = 1_000_000
118
+ const onFloodWait = vi.fn()
119
+ const { sendReaction, setMessageReaction } = wireReactionPath({
120
+ clock: fixedClock(now),
121
+ enabled: true, // gate on, no open window ⇒ the cosmetic call is admitted
122
+ onFloodWait,
123
+ })
124
+ // The reaction send hits a Telegram 429 flood-wait.
125
+ setMessageReaction.mockRejectedValue(errors.floodWait(7, 'setMessageReaction'))
126
+
127
+ // Fire-and-forget semantics: callers swallow. The point is the SIDE EFFECT.
128
+ await sendReaction('chatA', 42, '🤝').catch(() => {})
129
+
130
+ // The reaction actually reached the API (was admitted, not shed)...
131
+ expect(setMessageReaction).toHaveBeenCalled()
132
+ // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
+ expect(onFloodWait).toHaveBeenCalledWith(7)
134
+ })
135
+ })
136
+
137
+ describe('#3155 load-bearing: gateway.ts sends no reaction raw', () => {
138
+ const src = readFileSync(GATEWAY_SRC, 'utf-8')
139
+ const lines = src.split('\n')
140
+
141
+ /** Non-comment source lines that call `(bot|lockedBot).api.setMessageReaction`. */
142
+ const rawReactionLines = lines
143
+ .map((text, i) => ({ text, n: i + 1 }))
144
+ .filter(({ text }) => {
145
+ const t = text.trim()
146
+ if (t.startsWith('*') || t.startsWith('//') || t.startsWith('/*')) return false
147
+ return /\b(bot|lockedBot)\.api\.setMessageReaction\b/.test(text)
148
+ })
149
+
150
+ it('has EXACTLY ONE runtime setMessageReaction callsite (the shared seam)', () => {
151
+ // If this fails with >1, a raw reaction was re-introduced somewhere —
152
+ // ungated and invisible to the flood breaker. Route it through
153
+ // `sendReaction` / `gatedSetMessageReaction` instead.
154
+ expect(rawReactionLines).toHaveLength(1)
155
+ })
156
+
157
+ it('the sole seam is tagged cosmetic and routed through robustApiCall', () => {
158
+ const seamLine = rawReactionLines[0]!.n
159
+ // Look at the seam line + a small window for its opts object.
160
+ const window = lines.slice(seamLine - 2, seamLine + 6).join('\n')
161
+ expect(window).toContain('robustApiCall(')
162
+ expect(window).toContain("priorityClass: 'cosmetic'")
163
+ expect(window).toContain("verb: 'set-message-reaction'")
164
+ })
165
+
166
+ it('no reaction is fired as a raw fire-and-forget bot.api call', () => {
167
+ // Belt-and-braces: the old `bot.api.setMessageReaction(...).catch(() => {})`
168
+ // shape must be gone entirely.
169
+ expect(src).not.toMatch(/bot\.api\.setMessageReaction\([^)]*\)\s*\.catch/)
170
+ // And the fire-and-forget callers now go through the helper.
171
+ expect(src).toContain('void sendReaction(')
172
+ })
173
+ })
@@ -17,8 +17,10 @@ import { describe, it, expect } from "vitest";
17
17
  import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
18
18
  import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
19
19
 
20
- const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
21
- const OFF = {} as NodeJS.ProcessEnv;
20
+ // Rendering is ON BY DEFAULT (escape hatch, not opt-in — mirrors the send
21
+ // gate): an empty env exercises the real default; "0" is the kill-switch.
22
+ const ON = {} as NodeJS.ProcessEnv;
23
+ const OFF = { SWITCHROOM_RICH_RENDER: "0" } as NodeJS.ProcessEnv;
22
24
 
23
25
  /** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
24
26
  * fenced block has an ODD count. */
@@ -27,7 +29,7 @@ function fenceCount(s: string): number {
27
29
  }
28
30
 
29
31
  describe("renderOutboundChunks", () => {
30
- it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
32
+ it("disabled (=0) is a single passthrough piece (byte-for-byte)", () => {
31
33
  const raw = "**bold** and _italic_ | a | table |";
32
34
  const pieces = renderOutboundChunks(raw, OFF);
33
35
  expect(pieces).toHaveLength(1);
@@ -35,7 +37,7 @@ describe("renderOutboundChunks", () => {
35
37
  expect(pieces[0].mode).toBe("markdown");
36
38
  });
37
39
 
38
- it("flag ON, body that fits is a single piece (common case)", () => {
40
+ it("default (env unset), body that fits is a single piece (common case)", () => {
39
41
  const pieces = renderOutboundChunks("just some plain prose", ON);
40
42
  expect(pieces).toHaveLength(1);
41
43
  expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);