switchroom 0.18.12 → 0.18.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -542,6 +542,121 @@ describe('hasOutboundDeliveredSince', () => {
542
542
  })
543
543
  })
544
544
 
545
+ // Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
546
+ // NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
547
+ // in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
548
+ // already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
549
+ // DUPLICATE row instead of replacing — over-counting the silence / over-ping
550
+ // detectors. The COALESCE(thread_id,'') unique index makes the upsert
551
+ // idempotent. These tests fail (duplicate rows) without the fix.
552
+ describe('idempotent upsert for null-thread rows (H5)', () => {
553
+ beforeEach(() => initHistory(stateDir, 30))
554
+
555
+ it('re-recording the same null-thread inbound yields exactly ONE row', () => {
556
+ const msg = {
557
+ chat_id: '-100',
558
+ thread_id: null,
559
+ message_id: 7,
560
+ user: 'alice',
561
+ user_id: '111',
562
+ ts: 1000,
563
+ text: 'hello',
564
+ }
565
+ // Simulate the at-least-once replay: record the same message twice.
566
+ recordInbound(msg)
567
+ recordInbound(msg)
568
+ const rows = query({ chat_id: '-100' })
569
+ expect(rows).toHaveLength(1)
570
+ expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
571
+ // The over-count surface the finding calls out must stay accurate.
572
+ expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
573
+ })
574
+
575
+ it('re-recording the same null-thread outbound yields exactly ONE row', () => {
576
+ const now = Math.floor(Date.now() / 1000)
577
+ const send = {
578
+ chat_id: '-100',
579
+ thread_id: null,
580
+ message_ids: [42],
581
+ texts: ['the answer'],
582
+ ts: now,
583
+ }
584
+ recordOutbound(send)
585
+ recordOutbound(send)
586
+ const rows = query({ chat_id: '-100' })
587
+ expect(rows).toHaveLength(1)
588
+ // hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
589
+ // duplicate here would double-count and over-ping.
590
+ expect(getRecentOutboundCount('-100', 60)).toBe(1)
591
+ })
592
+
593
+ it('a re-record REPLACES rather than appends (newest text wins)', () => {
594
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
595
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
596
+ const rows = query({ chat_id: '-100' })
597
+ expect(rows).toHaveLength(1)
598
+ expect(rows[0]?.text).toBe('second version')
599
+ })
600
+
601
+ it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
602
+ // Same chat_id + message_id, but one is in a forum topic (thread 5) and one
603
+ // is the general/DM row (null thread). These are different logical messages
604
+ // and must both survive — the COALESCE sentinel keeps them separate.
605
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
606
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
607
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
608
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
609
+ expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
610
+ })
611
+
612
+ it('two distinct forum topics with the same message_id stay DISTINCT', () => {
613
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
614
+ recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
615
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
616
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
617
+ expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
618
+ })
619
+
620
+ it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
621
+ // Simulate a DB written by the pre-fix build: insert duplicate null-thread
622
+ // rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
623
+ // corruption the finding describes. Then re-init to fire the migration.
624
+ const { Database } = await import('bun:sqlite')
625
+ const dbPath = join(stateDir, 'history.db')
626
+ const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
627
+ _resetForTests()
628
+ const raw = new Database(dbPath)
629
+ // Drop the logical-key index so we're back to the legacy, dupe-permitting
630
+ // schema, then append two rows sharing the same (chat, null-thread, msg).
631
+ raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
632
+ const insert = raw.prepare(
633
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
634
+ VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
635
+ )
636
+ insert.run('-100', 7, now, 'stale copy')
637
+ insert.run('-100', 7, now + 1, 'newest copy')
638
+ // A distinct topic row with the same message_id must survive the migration.
639
+ raw.prepare(
640
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
641
+ VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
642
+ ).run('-100', 7, now, 'topic row')
643
+ raw.close()
644
+
645
+ // Re-open through initHistory → runs the H5 migration (dedupe + index).
646
+ initHistory(stateDir, 30)
647
+ const general = query({ chat_id: '-100', thread_id: null })
648
+ expect(general).toHaveLength(1)
649
+ expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
650
+ // The topic row with the same message_id is untouched.
651
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
652
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
653
+
654
+ // And the upsert is idempotent going forward.
655
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
656
+ expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
657
+ })
658
+ })
659
+
545
660
  describe('secret redaction at persistence (both directions)', () => {
546
661
  beforeEach(() => initHistory(stateDir, 30))
547
662
 
@@ -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', () => {
@@ -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
+ })