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
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Unit tests for the forwarded-message origin helpers
3
+ * (telegram-plugin/gateway/forward-origin.ts).
4
+ *
5
+ * These pin the pure pieces of the forward-origin metadata path that live
6
+ * outside gateway.ts so they can be exercised without loadAccess()/IPC:
7
+ * 1. parseForwardOrigin — all four Bot API 7.0 origin shapes, missing
8
+ * fields, hostile (attacker-controlled) names, hidden_user marking.
9
+ * 2. buildForwardOriginMeta — fixed attribute order, XML escaping at the
10
+ * channel-meta boundary, numbered `_2..` siblings for multi-origin
11
+ * bursts (same convention as image_path_2 / attachment_file_id_2).
12
+ * 3. dedupeForwardOrigins — the coalescer's burst collapse: a
13
+ * single-origin album emits one attr set; distinct origins keep
14
+ * arrival order and get numbered.
15
+ *
16
+ * Trust model under test: origin info rides the ATTRS lane only, names are
17
+ * truncated + escaped (they're attacker-controlled), and hidden_user is
18
+ * explicitly marked so agents don't treat a self-reported display name as
19
+ * an authenticated identity.
20
+ */
21
+
22
+ import { describe, expect, it } from 'vitest'
23
+ import type { MessageOrigin } from 'grammy/types'
24
+ import {
25
+ parseForwardOrigin,
26
+ buildForwardOriginMeta,
27
+ dedupeForwardOrigins,
28
+ forwardOriginKey,
29
+ forwardOriginDateIso,
30
+ FORWARDED_FROM_NAME_MAX,
31
+ type ForwardOriginInfo,
32
+ } from '../gateway/forward-origin.js'
33
+
34
+ // Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
35
+ const DATE = 1750000000 // unix seconds
36
+ const DATE_ISO = new Date(DATE * 1000).toISOString()
37
+
38
+ function userOrigin(overrides: Partial<{
39
+ first_name: string
40
+ last_name?: string
41
+ username?: string
42
+ id: number
43
+ }> = {}): MessageOrigin {
44
+ return {
45
+ type: 'user',
46
+ date: DATE,
47
+ sender_user: {
48
+ id: 42,
49
+ is_bot: false,
50
+ first_name: 'Ada',
51
+ last_name: 'Lovelace',
52
+ username: 'adalove',
53
+ ...overrides,
54
+ },
55
+ }
56
+ }
57
+
58
+ describe('parseForwardOrigin — the four origin shapes', () => {
59
+ it('user: first + last name plus (@username), id and date captured', () => {
60
+ const info = parseForwardOrigin(userOrigin())
61
+ expect(info).toEqual({
62
+ name: 'Ada Lovelace (@adalove)',
63
+ type: 'user',
64
+ id: 42,
65
+ date: DATE,
66
+ })
67
+ })
68
+
69
+ it('user: first name only, no username', () => {
70
+ const info = parseForwardOrigin(userOrigin({ last_name: undefined, username: undefined }))
71
+ expect(info?.name).toBe('Ada')
72
+ expect(info?.id).toBe(42)
73
+ })
74
+
75
+ it('hidden_user: self-reported name, marked hidden_user, NO id', () => {
76
+ const info = parseForwardOrigin({
77
+ type: 'hidden_user',
78
+ date: DATE,
79
+ sender_user_name: 'Mystery Sender',
80
+ })
81
+ expect(info).toEqual({
82
+ name: 'Mystery Sender',
83
+ type: 'hidden_user',
84
+ date: DATE,
85
+ })
86
+ // The whole point of the type marker: no verifiable id exists.
87
+ expect(info?.id).toBeUndefined()
88
+ })
89
+
90
+ it('chat: sender_chat title plus (@username)', () => {
91
+ const info = parseForwardOrigin({
92
+ type: 'chat',
93
+ date: DATE,
94
+ sender_chat: { id: -100200300, type: 'supergroup', title: 'Ops Room', username: 'opsroom' } as never,
95
+ })
96
+ expect(info).toEqual({
97
+ name: 'Ops Room (@opsroom)',
98
+ type: 'chat',
99
+ id: -100200300,
100
+ date: DATE,
101
+ })
102
+ })
103
+
104
+ it('channel: chat.title plus (@username), message_id captured', () => {
105
+ const info = parseForwardOrigin({
106
+ type: 'channel',
107
+ date: DATE,
108
+ message_id: 555,
109
+ chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
110
+ })
111
+ expect(info).toEqual({
112
+ name: 'Release Notes (@relnotes)',
113
+ type: 'channel',
114
+ id: -100400500,
115
+ date: DATE,
116
+ messageId: 555,
117
+ })
118
+ })
119
+ })
120
+
121
+ describe('parseForwardOrigin — missing / malformed fields', () => {
122
+ it('returns undefined for a non-forwarded message (no origin)', () => {
123
+ expect(parseForwardOrigin(undefined)).toBeUndefined()
124
+ })
125
+
126
+ it('returns undefined for an unknown future origin type', () => {
127
+ expect(parseForwardOrigin({ type: 'giveaway', date: DATE } as never)).toBeUndefined()
128
+ })
129
+
130
+ it('user origin without sender_user is dropped', () => {
131
+ expect(parseForwardOrigin({ type: 'user', date: DATE } as never)).toBeUndefined()
132
+ })
133
+
134
+ it('hidden_user with an empty name is dropped (nothing to show)', () => {
135
+ expect(
136
+ parseForwardOrigin({ type: 'hidden_user', date: DATE, sender_user_name: '' }),
137
+ ).toBeUndefined()
138
+ })
139
+
140
+ it('user with no printable name falls back to the numeric id as name', () => {
141
+ const info = parseForwardOrigin({
142
+ type: 'user',
143
+ date: DATE,
144
+ sender_user: { id: 42, is_bot: false, first_name: '' },
145
+ })
146
+ expect(info).toEqual({ name: '42', type: 'user', id: 42, date: DATE })
147
+ })
148
+
149
+ it('missing date yields no date (and later no forwarded_date attr)', () => {
150
+ const info = parseForwardOrigin({
151
+ type: 'hidden_user',
152
+ sender_user_name: 'Mystery Sender',
153
+ } as never)
154
+ expect(info?.date).toBeUndefined()
155
+ expect(buildForwardOriginMeta([info!]).forwarded_date).toBeUndefined()
156
+ expect(forwardOriginDateIso(info)).toBeNull()
157
+ })
158
+
159
+ it('channel origin without a title falls back to first/last name shape, then id', () => {
160
+ const noName = parseForwardOrigin({
161
+ type: 'channel',
162
+ date: DATE,
163
+ message_id: 9,
164
+ chat: { id: -100777888, type: 'channel' } as never,
165
+ })
166
+ expect(noName).toEqual({
167
+ name: '-100777888',
168
+ type: 'channel',
169
+ id: -100777888,
170
+ date: DATE,
171
+ messageId: 9,
172
+ })
173
+ })
174
+ })
175
+
176
+ describe('parseForwardOrigin — hostile names (attacker-controlled)', () => {
177
+ it('truncates a 4KB name to the cap (raw, ellipsis-terminated)', () => {
178
+ const bomb = 'x'.repeat(4096)
179
+ const info = parseForwardOrigin(userOrigin({ first_name: bomb, last_name: undefined, username: undefined }))
180
+ expect(info?.name).toHaveLength(FORWARDED_FROM_NAME_MAX)
181
+ expect(info?.name?.endsWith('…')).toBe(true)
182
+ })
183
+
184
+ it('keeps XML metacharacters RAW in the parsed record (SQLite lane)', () => {
185
+ const info = parseForwardOrigin(
186
+ userOrigin({ first_name: '<b>"Bob"&\'friends\'</b>', last_name: undefined, username: undefined }),
187
+ )
188
+ // Escaping happens at the channel-meta boundary, not at parse time —
189
+ // the history buffer stores what the user actually saw.
190
+ expect(info?.name).toBe('<b>"Bob"&\'friends\'</b>')
191
+ })
192
+ })
193
+
194
+ describe('buildForwardOriginMeta — channel-tag attrs', () => {
195
+ it('single origin: bare keys in the fixed order name, type, id, date', () => {
196
+ const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
197
+ expect(Object.keys(meta)).toEqual([
198
+ 'forwarded_from',
199
+ 'forwarded_from_type',
200
+ 'forwarded_from_id',
201
+ 'forwarded_date',
202
+ ])
203
+ expect(meta).toEqual({
204
+ forwarded_from: 'Ada Lovelace (@adalove)',
205
+ forwarded_from_type: 'user',
206
+ forwarded_from_id: '42',
207
+ forwarded_date: DATE_ISO,
208
+ })
209
+ })
210
+
211
+ it('id-less origin (hidden_user) omits forwarded_from_id entirely', () => {
212
+ const meta = buildForwardOriginMeta([
213
+ { name: 'Mystery Sender', type: 'hidden_user', date: DATE },
214
+ ])
215
+ expect(Object.keys(meta)).toEqual([
216
+ 'forwarded_from',
217
+ 'forwarded_from_type',
218
+ 'forwarded_date',
219
+ ])
220
+ expect(meta.forwarded_from_type).toBe('hidden_user')
221
+ })
222
+
223
+ it('XML-escapes hostile names — the rendered attribute value cannot break out', () => {
224
+ const meta = buildForwardOriginMeta([
225
+ { name: '<script>"a"&\'b\'</script>', type: 'user', id: 42, date: DATE },
226
+ ])
227
+ expect(meta.forwarded_from).toBe(
228
+ '&lt;script&gt;&quot;a&quot;&amp;&apos;b&apos;&lt;/script&gt;',
229
+ )
230
+ // Outcome assertion: rendered into a channel tag, the value carries no
231
+ // raw quote/angle characters that could terminate the attribute.
232
+ const rendered = `<channel source="telegram" forwarded_from="${meta.forwarded_from}">`
233
+ expect(rendered).not.toMatch(/forwarded_from="[^"]*[<>][^"]*"/)
234
+ expect((rendered.match(/"/g) ?? []).length).toBe(4) // only the delimiters
235
+ })
236
+
237
+ it('defense in depth: a name that skipped parse-time truncation is capped here too', () => {
238
+ const meta = buildForwardOriginMeta([
239
+ { name: 'y'.repeat(4096), type: 'user', id: 42, date: DATE },
240
+ ])
241
+ // 99 chars + '…' — no 4KB payload reaches the tag.
242
+ expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
243
+ })
244
+
245
+ it('no origins → empty record (no attrs on a normal message)', () => {
246
+ expect(buildForwardOriginMeta([])).toEqual({})
247
+ })
248
+ })
249
+
250
+ describe('coalesced bursts — dedupe + numbered siblings', () => {
251
+ const alice: ForwardOriginInfo = { name: 'Alice Q (@aliceq)', type: 'user', id: 42, date: DATE }
252
+ const relnotes: ForwardOriginInfo = {
253
+ name: 'Release Notes (@relnotes)',
254
+ type: 'channel',
255
+ id: -100400500,
256
+ date: DATE + 60,
257
+ messageId: 7,
258
+ }
259
+
260
+ it('single-origin album (N parts, one sender) collapses to ONE attr set', () => {
261
+ const origins = dedupeForwardOrigins([alice, { ...alice, date: DATE + 5 }, { ...alice, date: DATE + 9 }])
262
+ expect(origins).toHaveLength(1)
263
+ const meta = buildForwardOriginMeta(origins)
264
+ expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
265
+ expect(meta.forwarded_from_2).toBeUndefined()
266
+ // First occurrence wins — the emitted date is the first part's.
267
+ expect(meta.forwarded_date).toBe(DATE_ISO)
268
+ })
269
+
270
+ it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
271
+ const meta = buildForwardOriginMeta(dedupeForwardOrigins([alice, relnotes]))
272
+ expect(Object.keys(meta)).toEqual([
273
+ 'forwarded_from',
274
+ 'forwarded_from_type',
275
+ 'forwarded_from_id',
276
+ 'forwarded_date',
277
+ 'forwarded_from_2',
278
+ 'forwarded_from_type_2',
279
+ 'forwarded_from_id_2',
280
+ 'forwarded_date_2',
281
+ ])
282
+ expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
283
+ expect(meta.forwarded_from_type_2).toBe('channel')
284
+ expect(meta.forwarded_from_id_2).toBe('-100400500')
285
+ })
286
+
287
+ it('non-forwarded entries in the burst are skipped', () => {
288
+ expect(dedupeForwardOrigins([undefined, alice, undefined])).toEqual([alice])
289
+ expect(dedupeForwardOrigins([undefined, undefined])).toEqual([])
290
+ })
291
+
292
+ it('id-less origins dedupe by name; same type+id dedupes despite name drift', () => {
293
+ const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
294
+ expect(dedupeForwardOrigins([hidden, { ...hidden, date: DATE + 3 }])).toHaveLength(1)
295
+ // Same user id, renamed mid-burst — still one origin.
296
+ expect(dedupeForwardOrigins([alice, { ...alice, name: 'Alice Renamed' }])).toHaveLength(1)
297
+ // Same numeric id but different type stays distinct.
298
+ expect(forwardOriginKey(alice)).not.toBe(forwardOriginKey({ ...alice, type: 'chat' }))
299
+ })
300
+
301
+ it('arrival order is preserved across three distinct origins', () => {
302
+ const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
303
+ const meta = buildForwardOriginMeta(dedupeForwardOrigins([relnotes, hidden, alice]))
304
+ expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
305
+ expect(meta.forwarded_from_2).toBe('Mystery Sender')
306
+ expect(meta.forwarded_from_3).toBe('Alice Q (@aliceq)')
307
+ expect(meta.forwarded_from_type_3).toBe('user')
308
+ })
309
+ })
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
77
77
  expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
78
78
  })
79
79
 
80
+ it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
81
+ // F1 — ask_user sent the question + button labels unredacted. The scrub
82
+ // must run at the top of executeAskUser (via redactAskUserFields), before
83
+ // the keyboard is built and the question is sent to Telegram.
84
+ const start = src.indexOf('async function executeAskUser(')
85
+ const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
86
+ const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
87
+ const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
88
+ expect(start).toBeGreaterThan(0)
89
+ expect(redactIdx).toBeGreaterThan(start)
90
+ expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
91
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
92
+ })
93
+
94
+ it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
95
+ // F2 — checklist title + task strings were sent unredacted.
96
+ const start = src.indexOf('async function executeSendChecklist(')
97
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
98
+ const sendIdx = src.indexOf('rawSendChecklist({', start)
99
+ expect(start).toBeGreaterThan(0)
100
+ expect(redactIdx).toBeGreaterThan(start)
101
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
102
+ })
103
+
104
+ it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
105
+ // F2 sibling — update_checklist shares the identical leak class.
106
+ const start = src.indexOf('async function executeUpdateChecklist(')
107
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
108
+ const editIdx = src.indexOf('rawEditMessageChecklist({', start)
109
+ expect(start).toBeGreaterThan(0)
110
+ expect(redactIdx).toBeGreaterThan(start)
111
+ expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
112
+ })
113
+
114
+ it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
115
+ // F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
116
+ // forwards straight to the draft-preview stream. Mask at the top of
117
+ // handlePtyPartial, before it hands off to handlePtyPartialPure.
118
+ const start = src.indexOf('function handlePtyPartial(text: string): void {')
119
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
120
+ const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
121
+ expect(start).toBeGreaterThan(0)
122
+ expect(redactIdx).toBeGreaterThan(start)
123
+ expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
124
+ })
125
+
126
+ it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
127
+ // F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
128
+ // is durable if it is ever re-armed.
129
+ const start = src.indexOf('function handlePtyActivity(text: string): void {')
130
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
131
+ const streamIdx = src.indexOf('handleStreamReply(', start)
132
+ expect(start).toBeGreaterThan(0)
133
+ expect(redactIdx).toBeGreaterThan(start)
134
+ expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
+ })
136
+
80
137
  it('does not log the secret value when a mask fires', () => {
81
138
  const idx = src.indexOf('function redactOutboundText(')
82
139
  const body = src.slice(idx, idx + 400)
@@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { mkdtempSync, statSync, rmSync, existsSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
+ // bun-only (this file is vitest-excluded and runs under `bun test`): used to
6
+ // build an OLD-schema DB file for the additive-migration test.
7
+ import { Database } from 'bun:sqlite'
5
8
  import {
6
9
  initHistory,
7
10
  recordInbound,
@@ -542,6 +545,121 @@ describe('hasOutboundDeliveredSince', () => {
542
545
  })
543
546
  })
544
547
 
548
+ // Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
549
+ // NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
550
+ // in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
551
+ // already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
552
+ // DUPLICATE row instead of replacing — over-counting the silence / over-ping
553
+ // detectors. The COALESCE(thread_id,'') unique index makes the upsert
554
+ // idempotent. These tests fail (duplicate rows) without the fix.
555
+ describe('idempotent upsert for null-thread rows (H5)', () => {
556
+ beforeEach(() => initHistory(stateDir, 30))
557
+
558
+ it('re-recording the same null-thread inbound yields exactly ONE row', () => {
559
+ const msg = {
560
+ chat_id: '-100',
561
+ thread_id: null,
562
+ message_id: 7,
563
+ user: 'alice',
564
+ user_id: '111',
565
+ ts: 1000,
566
+ text: 'hello',
567
+ }
568
+ // Simulate the at-least-once replay: record the same message twice.
569
+ recordInbound(msg)
570
+ recordInbound(msg)
571
+ const rows = query({ chat_id: '-100' })
572
+ expect(rows).toHaveLength(1)
573
+ expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
574
+ // The over-count surface the finding calls out must stay accurate.
575
+ expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
576
+ })
577
+
578
+ it('re-recording the same null-thread outbound yields exactly ONE row', () => {
579
+ const now = Math.floor(Date.now() / 1000)
580
+ const send = {
581
+ chat_id: '-100',
582
+ thread_id: null,
583
+ message_ids: [42],
584
+ texts: ['the answer'],
585
+ ts: now,
586
+ }
587
+ recordOutbound(send)
588
+ recordOutbound(send)
589
+ const rows = query({ chat_id: '-100' })
590
+ expect(rows).toHaveLength(1)
591
+ // hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
592
+ // duplicate here would double-count and over-ping.
593
+ expect(getRecentOutboundCount('-100', 60)).toBe(1)
594
+ })
595
+
596
+ it('a re-record REPLACES rather than appends (newest text wins)', () => {
597
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
598
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
599
+ const rows = query({ chat_id: '-100' })
600
+ expect(rows).toHaveLength(1)
601
+ expect(rows[0]?.text).toBe('second version')
602
+ })
603
+
604
+ it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
605
+ // Same chat_id + message_id, but one is in a forum topic (thread 5) and one
606
+ // is the general/DM row (null thread). These are different logical messages
607
+ // and must both survive — the COALESCE sentinel keeps them separate.
608
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
609
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
610
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
611
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
612
+ expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
613
+ })
614
+
615
+ it('two distinct forum topics with the same message_id stay DISTINCT', () => {
616
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
617
+ recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
618
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
619
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
620
+ expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
621
+ })
622
+
623
+ it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
624
+ // Simulate a DB written by the pre-fix build: insert duplicate null-thread
625
+ // rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
626
+ // corruption the finding describes. Then re-init to fire the migration.
627
+ const { Database } = await import('bun:sqlite')
628
+ const dbPath = join(stateDir, 'history.db')
629
+ const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
630
+ _resetForTests()
631
+ const raw = new Database(dbPath)
632
+ // Drop the logical-key index so we're back to the legacy, dupe-permitting
633
+ // schema, then append two rows sharing the same (chat, null-thread, msg).
634
+ raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
635
+ const insert = raw.prepare(
636
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
637
+ VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
638
+ )
639
+ insert.run('-100', 7, now, 'stale copy')
640
+ insert.run('-100', 7, now + 1, 'newest copy')
641
+ // A distinct topic row with the same message_id must survive the migration.
642
+ raw.prepare(
643
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
644
+ VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
645
+ ).run('-100', 7, now, 'topic row')
646
+ raw.close()
647
+
648
+ // Re-open through initHistory → runs the H5 migration (dedupe + index).
649
+ initHistory(stateDir, 30)
650
+ const general = query({ chat_id: '-100', thread_id: null })
651
+ expect(general).toHaveLength(1)
652
+ expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
653
+ // The topic row with the same message_id is untouched.
654
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
655
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
656
+
657
+ // And the upsert is idempotent going forward.
658
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
659
+ expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
660
+ })
661
+ })
662
+
545
663
  describe('secret redaction at persistence (both directions)', () => {
546
664
  beforeEach(() => initHistory(stateDir, 30))
547
665
 
@@ -600,3 +718,157 @@ describe('secret redaction at persistence (both directions)', () => {
600
718
  expect(query({ chat_id: '-100' })[0]!.text).toBe('hello, how are you?')
601
719
  })
602
720
  })
721
+
722
+ describe('forwarded-message origin columns', () => {
723
+ it('round-trips forwarded_* fields on an inbound row', () => {
724
+ initHistory(stateDir, 30)
725
+ recordInbound({
726
+ chat_id: '-100',
727
+ thread_id: null,
728
+ message_id: 7,
729
+ user: 'alice',
730
+ user_id: '111',
731
+ ts: 1000,
732
+ text: 'fwd: look at this',
733
+ forwarded_from: 'Release Notes (@relnotes)',
734
+ forwarded_from_type: 'channel',
735
+ forwarded_from_id: '-100400500',
736
+ forwarded_date: '2026-06-15T13:46:40.000Z',
737
+ forwarded_message_id: 555,
738
+ })
739
+ const row = query({ chat_id: '-100' })[0]!
740
+ expect(row).toMatchObject({
741
+ forwarded_from: 'Release Notes (@relnotes)',
742
+ forwarded_from_type: 'channel',
743
+ forwarded_from_id: '-100400500',
744
+ forwarded_date: '2026-06-15T13:46:40.000Z',
745
+ forwarded_message_id: 555,
746
+ })
747
+ })
748
+
749
+ it('non-forwarded inbound stores NULL origin fields', () => {
750
+ initHistory(stateDir, 30)
751
+ recordInbound({
752
+ chat_id: '-100',
753
+ thread_id: null,
754
+ message_id: 8,
755
+ user: 'alice',
756
+ user_id: '111',
757
+ ts: 1000,
758
+ text: 'plain message',
759
+ })
760
+ const row = query({ chat_id: '-100' })[0]!
761
+ expect(row.forwarded_from).toBeNull()
762
+ expect(row.forwarded_from_type).toBeNull()
763
+ expect(row.forwarded_from_id).toBeNull()
764
+ expect(row.forwarded_date).toBeNull()
765
+ expect(row.forwarded_message_id).toBeNull()
766
+ })
767
+
768
+ it('migrates additively: pre-existing DB without the columns gains them and round-trips', () => {
769
+ // Build an OLD-schema DB file the way a pre-forward-origin build would
770
+ // have left it: messages table without any forwarded_* columns, one row
771
+ // already stored.
772
+ const dbPath = join(stateDir, 'history.db')
773
+ const old = new Database(dbPath, { create: true })
774
+ old.exec(`
775
+ CREATE TABLE IF NOT EXISTS messages (
776
+ chat_id TEXT NOT NULL,
777
+ thread_id INTEGER,
778
+ message_id INTEGER NOT NULL,
779
+ role TEXT NOT NULL,
780
+ user TEXT,
781
+ user_id TEXT,
782
+ ts INTEGER NOT NULL,
783
+ text TEXT NOT NULL,
784
+ attachment_kind TEXT,
785
+ group_id INTEGER,
786
+ reply_to_message_id INTEGER,
787
+ reply_to_text TEXT,
788
+ PRIMARY KEY (chat_id, thread_id, message_id)
789
+ )
790
+ `)
791
+ // Recent ts — initHistory's retention sweep deletes rows older than the
792
+ // cutoff, and this test is about migration, not retention.
793
+ const now = Math.floor(Date.now() / 1000)
794
+ old.exec(
795
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text) ` +
796
+ `VALUES ('-100', NULL, 1, 'user', 'alice', '111', ${now - 60}, 'pre-migration row')`,
797
+ )
798
+ old.close()
799
+
800
+ // Re-open through the real init path — the additive ALTER TABLE loop
801
+ // must add the forwarded_* columns without touching existing rows.
802
+ initHistory(stateDir, 30)
803
+
804
+ const oldRow = query({ chat_id: '-100' })[0]!
805
+ expect(oldRow.text).toBe('pre-migration row')
806
+ expect(oldRow.forwarded_from).toBeNull()
807
+
808
+ recordInbound({
809
+ chat_id: '-100',
810
+ thread_id: null,
811
+ message_id: 2,
812
+ user: 'alice',
813
+ user_id: '111',
814
+ ts: now,
815
+ text: 'forwarded after migration',
816
+ forwarded_from: 'Ada Lovelace (@adalove)',
817
+ forwarded_from_type: 'user',
818
+ forwarded_from_id: '42',
819
+ forwarded_date: '2026-06-15T13:46:40.000Z',
820
+ })
821
+ const rows = query({ chat_id: '-100' })
822
+ expect(rows).toHaveLength(2)
823
+ const fwd = rows.find((r) => r.message_id === 2)!
824
+ expect(fwd.forwarded_from).toBe('Ada Lovelace (@adalove)')
825
+ expect(fwd.forwarded_from_type).toBe('user')
826
+ expect(fwd.forwarded_from_id).toBe('42')
827
+ expect(fwd.forwarded_date).toBe('2026-06-15T13:46:40.000Z')
828
+ expect(fwd.forwarded_message_id).toBeNull()
829
+ })
830
+
831
+ it('hostile origin name is stored raw (XML metachars belong to the meta lane)', () => {
832
+ initHistory(stateDir, 30)
833
+ // Raw XML metacharacters are EXPECTED here — escaping belongs to the
834
+ // channel-meta lane, the history buffer stores what the user saw.
835
+ recordInbound({
836
+ chat_id: '-100',
837
+ thread_id: null,
838
+ message_id: 9,
839
+ user: 'alice',
840
+ user_id: '111',
841
+ ts: 1000,
842
+ text: 'fwd',
843
+ forwarded_from: '<b>"Bob"&\'friends\'</b>',
844
+ forwarded_from_type: 'user',
845
+ forwarded_from_id: '42',
846
+ })
847
+ expect(query({ chat_id: '-100' })[0]!.forwarded_from).toBe('<b>"Bob"&\'friends\'</b>')
848
+ })
849
+
850
+ it('masks a secret-shaped origin name before it is stored (redaction backstop)', () => {
851
+ initHistory(stateDir, 30)
852
+ // Built by concatenation so the source never holds a contiguous
853
+ // secret-shaped literal (repo Push Protection / no-pii lint). Same
854
+ // pattern as the text/reply_to_text redaction tests above — a display
855
+ // name is user-controlled text and rides the same redact() backstop.
856
+ const GH_PAT = `ghp_${'F6g7H8i9J0'.repeat(3)}` // ghp_<30 base62>
857
+ recordInbound({
858
+ chat_id: '-100',
859
+ thread_id: null,
860
+ message_id: 10,
861
+ user: 'alice',
862
+ user_id: '111',
863
+ ts: 1000,
864
+ text: 'fwd',
865
+ forwarded_from: `Bob ${GH_PAT}`,
866
+ forwarded_from_type: 'user',
867
+ forwarded_from_id: '42',
868
+ })
869
+ const stored = query({ chat_id: '-100' })[0]!.forwarded_from as string
870
+ expect(stored).not.toContain(GH_PAT)
871
+ expect(stored).toContain('[REDACTED')
872
+ expect(stored).toContain('Bob') // surrounding name preserved
873
+ })
874
+ })
@@ -206,7 +206,11 @@ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
206
206
  const fnStart = SRC.indexOf('async function handleAckOnly(')
207
207
  const fnSlice = SRC.slice(fnStart, fnStart + 2000)
208
208
  expect(fnSlice).toContain('gate(ctx)')
209
- expect(fnSlice).toContain('setMessageReaction')
209
+ // #3155: the reaction now routes through the gated `sendReaction` helper
210
+ // (send gate + flood breaker, cosmetic) instead of a raw
211
+ // `bot.api.setMessageReaction`. The invariant is unchanged: it gates, THEN
212
+ // reacts.
213
+ expect(fnSlice).toContain('sendReaction(')
210
214
  })
211
215
 
212
216
  it('handleAckOnly drops non-allowlisted senders silently', () => {