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.
- package/dist/agent-scheduler/index.js +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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
|
-
|
|
21
|
-
|
|
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("
|
|
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("
|
|
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);
|